diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-5-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-5-chromium-linux.png index 7faf369647c50435999b5bed682446bbb3755b2d..94ee19d49d1ae63b55fbf2b9e7a7c0771b607640 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-5-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-5-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png index 623c76625f149b4c4ca542951ac28682af13bac5..d155399a88cc237103d1c0ef7977cbcc2820f6b6 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png index 78e8dc758e83ccc5e757410b5be2103d94c18371..2c233815a964b72a2031d58348742cbfe5792205 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-5-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-5-chromium-linux.png index 51e86751f51469bdf9ce19a2b809ec4249959e09..62f4c3567cdbeb063d6808fbe3890339ff08916a 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-5-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-opens-individual-account-page-and-checks-that-filtering-is-working-5-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index bcc1ade800f82e123f7f5037c1d642aacafcb33b..bdd7cc587c733b56ef18348f80ef283428469e16 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -6,16 +6,21 @@ import { useLocation } from 'react-router-dom'; import { type State } from 'loot-core/src/client/state-types'; import { type PopModalAction } from 'loot-core/src/client/state-types/modals'; import { send } from 'loot-core/src/platform/client/fetch'; +import * as monthUtils from 'loot-core/src/shared/months'; import { useActions } from '../hooks/useActions'; import { useSyncServerStatus } from '../hooks/useSyncServerStatus'; -import { CategoryGroupMenu } from './modals/CategoryGroupMenu'; -import { CategoryMenu } from './modals/CategoryMenu'; -import { CloseAccount } from './modals/CloseAccount'; +import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal'; +import { AccountMenuModal } from './modals/AccountMenuModal'; +import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal'; +import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal'; +import { CategoryMenuModal } from './modals/CategoryMenuModal'; +import { CloseAccountModal } from './modals/CloseAccountModal'; import { ConfirmCategoryDelete } from './modals/ConfirmCategoryDelete'; import { ConfirmTransactionEdit } from './modals/ConfirmTransactionEdit'; import { ConfirmUnlinkAccount } from './modals/ConfirmUnlinkAccount'; +import { CoverModal } from './modals/CoverModal'; import { CreateAccount } from './modals/CreateAccount'; import { CreateEncryptionKey } from './modals/CreateEncryptionKey'; import { CreateLocalAccount } from './modals/CreateLocalAccount'; @@ -24,22 +29,30 @@ import { EditRule } from './modals/EditRule'; import { FixEncryptionKey } from './modals/FixEncryptionKey'; import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg'; import { GoCardlessInitialise } from './modals/GoCardlessInitialise'; +import { HoldBufferModal } from './modals/HoldBufferModal'; import { ImportTransactions } from './modals/ImportTransactions'; import { LoadBackup } from './modals/LoadBackup'; import { ManageRulesModal } from './modals/ManageRulesModal'; import { MergeUnusedPayees } from './modals/MergeUnusedPayees'; import { Notes } from './modals/Notes'; +import { PayeeAutocompleteModal } from './modals/PayeeAutocompleteModal'; import { PlaidExternalMsg } from './modals/PlaidExternalMsg'; -import { ReportBudgetSummary } from './modals/ReportBudgetSummary'; -import { RolloverBudgetSummary } from './modals/RolloverBudgetSummary'; +import { ReportBalanceMenuModal } from './modals/ReportBalanceMenuModal'; +import { ReportBudgetSummaryModal } from './modals/ReportBudgetSummaryModal'; +import { RolloverBalanceMenuModal } from './modals/RolloverBalanceMenuModal'; +import { RolloverBudgetSummaryModal } from './modals/RolloverBudgetSummaryModal'; +import { RolloverToBudgetMenuModal } from './modals/RolloverToBudgetMenuModal'; +import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenuModal'; import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts'; import { SimpleFinInitialise } from './modals/SimpleFinInitialise'; -import { SingleInput } from './modals/SingleInput'; +import { SingleInputModal } from './modals/SingleInputModal'; import { SwitchBudgetType } from './modals/SwitchBudgetType'; +import { TransferModal } from './modals/TransferModal'; import { DiscoverSchedules } from './schedules/DiscoverSchedules'; import { PostsOfflineNotification } from './schedules/PostsOfflineNotification'; import { ScheduleDetails } from './schedules/ScheduleDetails'; import { ScheduleLink } from './schedules/ScheduleLink'; +import { NamespaceContext } from './spreadsheet/NamespaceContext'; export type CommonModalProps = { onClose: () => PopModalAction; @@ -97,12 +110,11 @@ export function Modals() { case 'close-account': return ( - <CloseAccount + <CloseAccountModal modalProps={modalProps} account={options.account} balance={options.balance} canDelete={options.canDelete} - actions={actions} /> ); @@ -255,9 +267,51 @@ export function Modals() { /> ); + case 'category-autocomplete': + return ( + <CategoryAutocompleteModal + key={name} + modalProps={modalProps} + autocompleteProps={{ + value: null, + categoryGroups: options.categoryGroups, + onSelect: options.onSelect, + showHiddenCategories: options.showHiddenCategories, + }} + onClose={options.onClose} + /> + ); + + case 'account-autocomplete': + return ( + <AccountAutocompleteModal + key={name} + modalProps={modalProps} + autocompleteProps={{ + value: null, + onSelect: options.onSelect, + includeClosedAccounts: options.includeClosedAccounts, + }} + onClose={options.onClose} + /> + ); + + case 'payee-autocomplete': + return ( + <PayeeAutocompleteModal + key={name} + modalProps={modalProps} + autocompleteProps={{ + value: null, + onSelect: options.onSelect, + }} + onClose={options.onClose} + /> + ); + case 'new-category': return ( - <SingleInput + <SingleInputModal modalProps={modalProps} title="New Category" inputPlaceholder="Category name" @@ -269,7 +323,7 @@ export function Modals() { case 'new-category-group': return ( - <SingleInput + <SingleInputModal modalProps={modalProps} title="New Category Group" inputPlaceholder="Category group name" @@ -281,17 +335,22 @@ export function Modals() { case 'rollover-budget-summary': return ( - <RolloverBudgetSummary + <NamespaceContext.Provider key={name} - modalProps={modalProps} - month={options.month} - onBudgetAction={options.onBudgetAction} - /> + value={monthUtils.sheetForMonth(options.month)} + > + <RolloverBudgetSummaryModal + key={name} + modalProps={modalProps} + month={options.month} + onBudgetAction={options.onBudgetAction} + /> + </NamespaceContext.Provider> ); case 'report-budget-summary': return ( - <ReportBudgetSummary + <ReportBudgetSummaryModal key={name} modalProps={modalProps} month={options.month} @@ -348,9 +407,23 @@ export function Modals() { /> ); + case 'account-menu': + return ( + <AccountMenuModal + key={name} + modalProps={modalProps} + accountId={options.accountId} + onSave={options.onSave} + onEditNotes={options.onEditNotes} + onCloseAccount={options.onCloseAccount} + onReopenAccount={options.onReopenAccount} + onClose={options.onClose} + /> + ); + case 'category-menu': return ( - <CategoryMenu + <CategoryMenuModal key={name} modalProps={modalProps} categoryId={options.categoryId} @@ -363,7 +436,7 @@ export function Modals() { case 'category-group-menu': return ( - <CategoryGroupMenu + <CategoryGroupMenuModal key={name} modalProps={modalProps} groupId={options.groupId} @@ -387,6 +460,95 @@ export function Modals() { /> ); + case 'rollover-balance-menu': + return ( + <NamespaceContext.Provider + key={name} + value={monthUtils.sheetForMonth(options.month)} + > + <RolloverBalanceMenuModal + modalProps={modalProps} + categoryId={options.categoryId} + onCarryover={options.onCarryover} + onTransfer={options.onTransfer} + onCover={options.onCover} + /> + </NamespaceContext.Provider> + ); + + case 'rollover-to-budget-menu': + return ( + <NamespaceContext.Provider + key={name} + value={monthUtils.sheetForMonth(options.month)} + > + <RolloverToBudgetMenuModal + modalProps={modalProps} + onTransfer={options.onTransfer} + onHoldBuffer={options.onHoldBuffer} + onResetHoldBuffer={options.onResetHoldBuffer} + /> + </NamespaceContext.Provider> + ); + + case 'hold-buffer': + return ( + <NamespaceContext.Provider + key={name} + value={monthUtils.sheetForMonth(options.month)} + > + <HoldBufferModal + modalProps={modalProps} + month={options.month} + onSubmit={options.onSubmit} + /> + </NamespaceContext.Provider> + ); + + case 'report-balance-menu': + return ( + <NamespaceContext.Provider + key={name} + value={monthUtils.sheetForMonth(options.month)} + > + <ReportBalanceMenuModal + modalProps={modalProps} + categoryId={options.categoryId} + onCarryover={options.onCarryover} + /> + </NamespaceContext.Provider> + ); + + case 'transfer': + return ( + <TransferModal + modalProps={modalProps} + title={options.title} + amount={options.amount} + onSubmit={options.onSubmit} + showToBeBudgeted={options.showToBeBudgeted} + /> + ); + + case 'cover': + return ( + <CoverModal + modalProps={modalProps} + categoryId={options.categoryId} + onSubmit={options.onSubmit} + /> + ); + + case 'scheduled-transaction-menu': + return ( + <ScheduledTransactionMenuModal + modalProps={modalProps} + transactionId={options.transactionId} + onPost={options.onPost} + onSkip={options.onSkip} + /> + ); + default: console.error('Unknown modal:', name); return null; diff --git a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.tsx index e6efee159a208bf5201d5bccf62984da0097ffb8..bc053ed99b2de88a74cf0193721133523fdaca9d 100644 --- a/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/AccountAutocomplete.tsx @@ -166,7 +166,7 @@ type AccountItemProps = { embedded?: boolean; }; -export function AccountItem({ +function AccountItem({ item, className, highlighted, diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index fc2617b930fc47e1447fc38dcebc05ce9740ee38..8ff9d228ff1e02a5bd12ff2b94edcf90eec35931 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -297,7 +297,7 @@ type CategoryItemProps = { embedded?: boolean; }; -export function CategoryItem({ +function CategoryItem({ item, className, style, diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index 5d660aab80ffa56fc70f92befff606c5fad1a303..a65ed413d7d421c0c995cf80f7f47918af91251c 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -398,6 +398,7 @@ type CreatePayeeButtonProps = { style?: CSSProperties; }; +// eslint-disable-next-line import/no-unused-modules export function CreatePayeeButton({ Icon, payeeName, @@ -471,7 +472,7 @@ type PayeeItemProps = { embedded?: boolean; }; -export function PayeeItem({ +function PayeeItem({ item, className, highlighted, diff --git a/packages/desktop-client/src/components/budget/index.tsx b/packages/desktop-client/src/components/budget/index.tsx index cad97746ba6d82c210c963d068153b00549495b9..a95139bf7201dd12bf2018c95f66820a7686327e 100644 --- a/packages/desktop-client/src/components/budget/index.tsx +++ b/packages/desktop-client/src/components/budget/index.tsx @@ -28,6 +28,7 @@ import { useLocalPref } from '../../hooks/useLocalPref'; import { useNavigate } from '../../hooks/useNavigate'; import { styles } from '../../style'; import { View } from '../common/View'; +import { NamespaceContext } from '../spreadsheet/NamespaceContext'; import { SWITCH_BUDGET_MESSAGE_TYPE, TitlebarContext, @@ -404,7 +405,11 @@ function BudgetInner(props: BudgetInnerProps) { ); } - return <View style={{ flex: 1 }}>{table}</View>; + return ( + <NamespaceContext.Provider value={monthUtils.sheetForMonth(startMonth)}> + <View style={{ flex: 1 }}>{table}</View> + </NamespaceContext.Provider> + ); } const RolloverBudgetSummary = memo<{ month: string }>(props => { diff --git a/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx index 0878a98310bdd0f24f2d36312e8818778ac8142d..0673620ff3734c2f57700538483c2d2318da71ef 100644 --- a/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx +++ b/packages/desktop-client/src/components/budget/report/BalanceMenu.tsx @@ -28,7 +28,7 @@ export function BalanceMenu({ onCarryover?.(!carryover); break; default: - throw new Error(`Unsupported item: ${name}`); + throw new Error(`Unrecognized menu option: ${name}`); } }} items={[ diff --git a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx index 803fcbe58b056f100bfabb632f0c8d503562ae48..3ec3c5f0f6c92d706cfe4c0c3427278f7db3da24 100644 --- a/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/BalanceMenu.tsx @@ -39,7 +39,7 @@ export function BalanceMenu({ onCover?.(); break; default: - throw new Error(`Unsupported item: ${name}`); + throw new Error(`Unrecognized menu option: ${name}`); } }} items={[ diff --git a/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx b/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx index ea05cd8737444ee10c9e1b6a6f1e1c6b1c7813da..bf68c07afaa6cdad2afee0ba0c8a2320a55d6697 100644 --- a/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx +++ b/packages/desktop-client/src/components/budget/rollover/RolloverContext.tsx @@ -9,7 +9,7 @@ type RolloverContextDefinition = { currentMonth: string; }; -const Context = createContext<RolloverContextDefinition>({ +const RolloverContext = createContext<RolloverContextDefinition>({ summaryCollapsed: false, onBudgetAction: () => { throw new Error('Unitialised context method called: onBudgetAction'); @@ -34,7 +34,7 @@ export function RolloverProvider({ const currentMonth = monthUtils.currentMonth(); return ( - <Context.Provider + <RolloverContext.Provider value={{ currentMonth, summaryCollapsed, @@ -43,10 +43,10 @@ export function RolloverProvider({ }} > {children} - </Context.Provider> + </RolloverContext.Provider> ); } export function useRollover() { - return useContext(Context); + return useContext(RolloverContext); } diff --git a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx index 183e7d28753c9ee11a208839b19bf924a10795b2..95cf54a8f14c7cb66634b76059f1c6bab2e1acc4 100644 --- a/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/budgetsummary/ToBudgetMenu.tsx @@ -31,7 +31,7 @@ export function ToBudgetMenu({ onResetHoldBuffer?.(); break; default: - throw new Error(`Unsupported item: ${name}`); + throw new Error(`Unrecognized menu option: ${name}`); } }} items={[ diff --git a/packages/desktop-client/src/components/mobile/MobileForms.tsx b/packages/desktop-client/src/components/mobile/MobileForms.tsx index 6141192bdab16a9418a6307e619a85f3af070c36..9b5394a024a9b5f5c5b879f62cd825fda8eb9b10 100644 --- a/packages/desktop-client/src/components/mobile/MobileForms.tsx +++ b/packages/desktop-client/src/components/mobile/MobileForms.tsx @@ -1,5 +1,5 @@ import React, { - type ComponentPropsWithoutRef, + type ComponentPropsWithRef, forwardRef, type ReactNode, } from 'react'; @@ -45,7 +45,7 @@ const valueStyle = { height: styles.mobileMinHeight, }; -type InputFieldProps = ComponentPropsWithoutRef<typeof Input>; +type InputFieldProps = ComponentPropsWithRef<typeof Input>; export const InputField = forwardRef<HTMLInputElement, InputFieldProps>( ({ disabled, style, onUpdate, ...props }, ref) => { @@ -72,7 +72,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>( InputField.displayName = 'InputField'; -type TapFieldProps = ComponentPropsWithoutRef<typeof Button> & { +type TapFieldProps = ComponentPropsWithRef<typeof Button> & { rightContent?: ReactNode; }; diff --git a/packages/desktop-client/src/components/mobile/accounts/Account.jsx b/packages/desktop-client/src/components/mobile/accounts/Account.jsx index 25c0bd4b17f0bd76fd2c47ca8bdf23e17638f7a1..4a07c391e6ede2b600d98f746b7ca4f4e882dd8d 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Account.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Account.jsx @@ -12,7 +12,7 @@ import { } from 'loot-core/src/client/data-hooks/schedules'; import * as queries from 'loot-core/src/client/queries'; import { pagedQuery } from 'loot-core/src/client/query-helpers'; -import { listen } from 'loot-core/src/platform/client/fetch'; +import { listen, send } from 'loot-core/src/platform/client/fetch'; import { isPreviewId, ungroupTransactions, @@ -126,6 +126,10 @@ export function Account(props) { updateQuery(query); }, [makeRootQuery, updateQuery]); + const refetchTransactions = () => { + paged.current?.run(); + }; + useEffect(() => { let unlisten; @@ -137,7 +141,7 @@ export function Account(props) { tables.includes('category_mapping') || tables.includes('payee_mapping') ) { - paged.current?.run(); + refetchTransactions(); } if (tables.includes('payees') || tables.includes('payee_mapping')) { @@ -222,6 +226,23 @@ export function Account(props) { // details of how the native app used to handle preview transactions here can be found at commit 05e58279 if (!isPreviewId(transaction.id)) { navigate(`transactions/${transaction.id}`); + } else { + dispatch( + actions.pushModal('scheduled-transaction-menu', { + transactionId: transaction.id, + onPost: async transactionId => { + const parts = transactionId.split('/'); + await send('schedule/post-transaction', { id: parts[1] }); + refetchTransactions(); + dispatch(actions.collapseModals('scheduled-transaction-menu')); + }, + onSkip: async transactionId => { + const parts = transactionId.split('/'); + await send('schedule/skip-next-date', { id: parts[1] }); + dispatch(actions.collapseModals('scheduled-transaction-menu')); + }, + }), + ); } }; diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx index b73909fd337bae126f03fb64ea4b8d23436817c0..be92e97ffa6fb4d35793e008ae2433cd03ade6de 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx @@ -1,7 +1,14 @@ import React, { useState, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import { syncAndDownload } from 'loot-core/client/actions'; +import { + openAccountCloseModal, + pushModal, + reopenAccount, + syncAndDownload, + updateAccount, +} from 'loot-core/client/actions'; +import { send } from 'loot-core/platform/client/fetch'; import { SvgAdd } from '../../../icons/v1'; import { SvgSearchAlternate } from '../../../icons/v2'; @@ -9,6 +16,7 @@ import { styles, theme } from '../../../style'; import { ButtonLink } from '../../common/ButtonLink'; import { InputWithContent } from '../../common/InputWithContent'; import { Label } from '../../common/Label'; +import { Text } from '../../common/Text'; import { View } from '../../common/View'; import { MobileBackButton } from '../../MobileBackButton'; import { Page } from '../../Page'; @@ -60,6 +68,79 @@ function TransactionSearchInput({ accountName, onSearch }) { ); } +function AccountName({ account, pending, failed }) { + const dispatch = useDispatch(); + + const onSave = account => { + dispatch(updateAccount(account)); + }; + + const onSaveNotes = async (id, notes) => { + await send('notes-save', { id, note: notes }); + }; + + const onEditNotes = () => { + dispatch( + pushModal('notes', { + id: account.id, + name: account.name, + onSave: onSaveNotes, + }), + ); + }; + + const onCloseAccount = () => { + dispatch(openAccountCloseModal(account.id)); + }; + + const onReopenAccount = () => { + dispatch(reopenAccount(account.id)); + }; + + const onClick = () => { + dispatch( + pushModal('account-menu', { + accountId: account.id, + onSave, + onEditNotes, + onCloseAccount, + onReopenAccount, + }), + ); + }; + return ( + <View + style={{ + flexDirection: 'row', + }} + > + {account.bankId && ( + <div + style={{ + margin: 'auto', + marginRight: 5, + width: 8, + height: 8, + borderRadius: 8, + backgroundColor: pending + ? theme.sidebarItemBackgroundPending + : failed + ? theme.sidebarItemBackgroundFailed + : theme.sidebarItemBackgroundPositive, + transition: 'transform .3s', + }} + /> + )} + <Text + style={{ ...styles.underlinedText, ...styles.lineClamp(2) }} + onClick={onClick} + > + {`${account.closed ? 'Closed: ' : ''}${account.name}`} + </Text> + </View> + ); +} + export function AccountDetails({ account, pending, @@ -89,32 +170,7 @@ export function AccountDetails({ return ( <Page title={ - !account.bankId ? ( - account.name - ) : ( - <View - style={{ - flexDirection: 'row', - }} - > - <div - style={{ - margin: 'auto', - marginRight: 3, - width: 8, - height: 8, - borderRadius: 8, - backgroundColor: pending - ? theme.sidebarItemBackgroundPending - : failed - ? theme.sidebarItemBackgroundFailed - : theme.sidebarItemBackgroundPositive, - transition: 'transform .3s', - }} - /> - {account.name} - </View> - ) + <AccountName account={account} pending={pending} failed={failed} /> } headerLeftContent={<MobileBackButton />} headerRightContent={ diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 92bdf7fd60203187defcf5b60cf993e17000f606..eed6c851f02713335e26a4eef55f051acc72d4ee 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -3,7 +3,7 @@ import { useDispatch } from 'react-redux'; import memoizeOne from 'memoize-one'; -import { pushModal } from 'loot-core/client/actions'; +import { collapseModals, pushModal } from 'loot-core/client/actions'; import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; @@ -21,8 +21,6 @@ import { import { useResponsive } from '../../../ResponsiveProvider'; import { theme, styles } from '../../../style'; import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover'; -import { BalanceTooltip as ReportBudgetBalanceTooltip } from '../../budget/report/BalanceTooltip'; -import { BalanceTooltip as RolloverBudgetBalanceTooltip } from '../../budget/rollover/BalanceTooltip'; import { makeAmountGrey } from '../../budget/util'; import { Button } from '../../common/Button'; import { Card } from '../../common/Card'; @@ -32,19 +30,13 @@ import { Text } from '../../common/Text'; import { View } from '../../common/View'; import { Page } from '../../Page'; import { CellValue } from '../../spreadsheet/CellValue'; -import { NamespaceContext } from '../../spreadsheet/NamespaceContext'; import { useFormat } from '../../spreadsheet/useFormat'; import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { Tooltip, useTooltip } from '../../tooltips'; import { AmountInput } from '../../util/AmountInput'; import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs'; import { PullToRefresh } from '../PullToRefresh'; -// import { -// AmountAccessoryContext, -// MathOperations -// } from '../mobile/AmountInput'; -// import { DragDrop, Draggable, Droppable, DragDropHighlight } from './dragdrop'; import { ListItem, ROW_HEIGHT } from './ListItem'; function ToBudget({ toBudget, onClick }) { @@ -257,10 +249,11 @@ const ExpenseCategory = memo(function ExpenseCategory({ showBudgetedCol, }) { const opacity = blank ? 0 : 1; - const balanceTooltip = useTooltip(); + const [budgetType = 'rollover'] = useLocalPref('budgetType'); const [isEditingBudget, setIsEditingBudget] = useState(false); const { onRequestActiveEdit, onClearActiveEdit } = useSingleActiveEditForm(); + const dispatch = useDispatch(); const onEditBudget = () => { onRequestActiveEdit(`${category.id}-budget`, () => { @@ -269,23 +262,64 @@ const ExpenseCategory = memo(function ExpenseCategory({ }); }; - const onOpenBalanceActionMenu = () => { - onRequestActiveEdit(`${category.id}-balance`, () => { - balanceTooltip.open(); - return () => balanceTooltip.close(); + const onCarryover = carryover => { + onBudgetAction(month, 'carryover', { + category: category.id, + flag: carryover, }); + dispatch(collapseModals(`${budgetType}-balance-menu`)); }; - const listItemRef = useRef(); + const catBalance = useSheetValue( + type === 'rollover' + ? rolloverBudget.catBalance(category.id) + : reportBudget.catBalance(category.id), + ); - const _onBudgetAction = (monthIndex, action, arg) => { - onBudgetAction?.( - monthUtils.getMonthFromIndex(monthUtils.getYear(month), monthIndex), - action, - arg, + const onTransfer = () => { + dispatch( + pushModal('transfer', { + title: `Transfer: ${category.name}`, + amount: catBalance, + onSubmit: (amount, toCategoryId) => { + onBudgetAction(month, 'transfer-category', { + amount, + from: category.id, + to: toCategoryId, + }); + }, + showToBeBudgeted: true, + }), ); }; + const onCover = () => { + dispatch( + pushModal('cover', { + categoryId: category.id, + onSubmit: fromCategoryId => { + onBudgetAction(month, 'cover', { + to: category.id, + from: fromCategoryId, + }); + }, + }), + ); + }; + + const onOpenBalanceActionMenu = () => { + dispatch( + pushModal(`${budgetType}-balance-menu`, { + categoryId: category.id, + month, + onCarryover, + ...(budgetType === 'rollover' && { onTransfer, onCover }), + }), + ); + }; + + const listItemRef = useRef(); + const content = ( <ListItem style={{ @@ -363,11 +397,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ height: ROW_HEIGHT, }} > - <span - role="button" - onPointerUp={() => onOpenBalanceActionMenu?.()} - onPointerDown={e => e.preventDefault()} - > + <span role="button" onClick={() => onOpenBalanceActionMenu?.()}> <BalanceWithCarryover carryover={carryover} balance={balance} @@ -378,30 +408,6 @@ const ExpenseCategory = memo(function ExpenseCategory({ ...styles.underlinedText, }} /> - {balanceTooltip.isOpen && - (type === 'report' ? ( - <ReportBudgetBalanceTooltip - offset={5} - categoryId={category.id} - tooltip={balanceTooltip} - monthIndex={monthUtils.getMonthIndex(month)} - onBudgetAction={_onBudgetAction} - onClose={() => { - onClearActiveEdit(); - }} - /> - ) : ( - <RolloverBudgetBalanceTooltip - offset={5} - categoryId={category.id} - tooltip={balanceTooltip} - monthIndex={monthUtils.getMonthIndex(month)} - onBudgetAction={_onBudgetAction} - onClose={() => { - onClearActiveEdit(); - }} - /> - ))} </span> </View> </View> @@ -1172,255 +1178,253 @@ export function BudgetTable({ }; return ( - <NamespaceContext.Provider value={monthUtils.sheetForMonth(month, type)}> - <Page - padding={0} - title={ - <MonthSelector - month={month} - monthBounds={monthBounds} - onPrevMonth={onPrevMonth} - onNextMonth={onNextMonth} + <Page + padding={0} + title={ + <MonthSelector + month={month} + monthBounds={monthBounds} + onPrevMonth={onPrevMonth} + onNextMonth={onNextMonth} + /> + } + headerRightContent={ + !editMode ? ( + <BudgetPageMenu + onEditMode={onEditMode} + onToggleHiddenCategories={onToggleHiddenCategories} + onSwitchBudgetType={_onSwitchBudgetType} /> - } - headerRightContent={ - !editMode ? ( - <BudgetPageMenu - onEditMode={onEditMode} - onToggleHiddenCategories={onToggleHiddenCategories} - onSwitchBudgetType={_onSwitchBudgetType} - /> - ) : ( - <Button - type="bare" - hoveredStyle={{ - color: theme.mobileHeaderText, - background: theme.mobileHeaderTextHover, - }} - style={{ - ...styles.noTapHighlight, - ...styles.text, - backgroundColor: 'transparent', - color: theme.mobileHeaderText, - }} - onClick={() => onEditMode?.(false)} - > - Done - </Button> - ) - } - style={{ flex: 1 }} + ) : ( + <Button + type="bare" + hoveredStyle={{ + color: theme.mobileHeaderText, + background: theme.mobileHeaderTextHover, + }} + style={{ + ...styles.noTapHighlight, + ...styles.text, + backgroundColor: 'transparent', + color: theme.mobileHeaderText, + }} + onClick={() => onEditMode?.(false)} + > + Done + </Button> + ) + } + style={{ flex: 1 }} + > + <View + style={{ + flexDirection: 'row', + flexShrink: 0, + padding: 10, + paddingRight: 14, + backgroundColor: theme.tableRowHeaderBackground, + borderBottomWidth: 1, + borderColor: theme.tableBorder, + }} > - <View - style={{ - flexDirection: 'row', - flexShrink: 0, - padding: 10, - paddingRight: 14, - backgroundColor: theme.tableRowHeaderBackground, - borderBottomWidth: 1, - borderColor: theme.tableBorder, - }} - > - {type === 'report' ? ( - <Saved - projected={month >= monthUtils.currentMonth()} - onClick={onShowBudgetSummary} - /> - ) : ( - <ToBudget - toBudget={rolloverBudget.toBudget} - onClick={onShowBudgetSummary} - /> - )} - <View style={{ flex: 1 }} /> - {(show3Cols || !showSpentColumn) && ( - <Button - type="bare" - disabled={show3Cols} - onClick={toggleDisplay} + {type === 'report' ? ( + <Saved + projected={month >= monthUtils.currentMonth()} + onClick={onShowBudgetSummary} + /> + ) : ( + <ToBudget + toBudget={rolloverBudget.toBudget} + onClick={onShowBudgetSummary} + /> + )} + <View style={{ flex: 1 }} /> + {(show3Cols || !showSpentColumn) && ( + <Button + type="bare" + disabled={show3Cols} + onClick={toggleDisplay} + style={{ + ...buttonStyle, + padding: '0 8px', + margin: '0 -8px', + background: + !showSpentColumn && !show3Cols + ? `linear-gradient(-45deg, ${theme.formInputBackgroundSelection} 8px, transparent 0)` + : null, + }} + > + <View style={{ - ...buttonStyle, - padding: '0 8px', - margin: '0 -8px', - background: - !showSpentColumn && !show3Cols - ? `linear-gradient(-45deg, ${theme.formInputBackgroundSelection} 8px, transparent 0)` - : null, + flexBasis: 90, + width: 90, + justifyContent: 'center', + alignItems: 'flex-end', }} > - <View + <Label + title="BUDGETED" + style={{ color: theme.buttonNormalText }} + /> + <CellValue + binding={ + type === 'report' + ? reportBudget.totalBudgetedExpense + : rolloverBudget.totalBudgeted + } + type="financial" style={{ - flexBasis: 90, - width: 90, - justifyContent: 'center', - alignItems: 'flex-end', + ...styles.smallText, + color: theme.buttonNormalText, + textAlign: 'right', + fontWeight: '500', + }} + formatter={value => { + return format(-parseFloat(value || '0'), 'financial'); }} - > - <Label - title="BUDGETED" - style={{ color: theme.buttonNormalText }} - /> - <CellValue - binding={ - type === 'report' - ? reportBudget.totalBudgetedExpense - : rolloverBudget.totalBudgeted - } - type="financial" - style={{ - ...styles.smallText, - color: theme.buttonNormalText, - textAlign: 'right', - fontWeight: '500', - }} - formatter={value => { - return format(-parseFloat(value || '0'), 'financial'); - }} - /> - </View> - </Button> - )} - {(show3Cols || showSpentColumn) && ( - <Button - type="bare" - disabled={show3Cols} - onClick={toggleDisplay} + /> + </View> + </Button> + )} + {(show3Cols || showSpentColumn) && ( + <Button + type="bare" + disabled={show3Cols} + onClick={toggleDisplay} + style={{ + ...buttonStyle, + background: + showSpentColumn && !show3Cols + ? `linear-gradient(45deg, ${theme.formInputBackgroundSelection} 8px, transparent 0)` + : null, + }} + > + <View style={{ - ...buttonStyle, - background: - showSpentColumn && !show3Cols - ? `linear-gradient(45deg, ${theme.formInputBackgroundSelection} 8px, transparent 0)` - : null, + width: 90, + justifyContent: 'center', + alignItems: 'flex-end', }} > - <View + <Label title="SPENT" style={{ color: theme.formInputText }} /> + <CellValue + binding={ + type === 'report' + ? reportBudget.totalSpent + : rolloverBudget.totalSpent + } + type="financial" style={{ - width: 90, - justifyContent: 'center', - alignItems: 'flex-end', + ...styles.smallText, + color: theme.formInputText, + textAlign: 'right', + fontWeight: '500', }} - > - <Label title="SPENT" style={{ color: theme.formInputText }} /> - <CellValue - binding={ - type === 'report' - ? reportBudget.totalSpent - : rolloverBudget.totalSpent - } - type="financial" - style={{ - ...styles.smallText, - color: theme.formInputText, - textAlign: 'right', - fontWeight: '500', - }} - /> - </View> - </Button> - )} + /> + </View> + </Button> + )} + <View + style={{ + width: 90, + justifyContent: 'center', + alignItems: 'flex-end', + }} + > + <Label title="BALANCE" style={{ color: theme.formInputText }} /> + <CellValue + binding={ + type === 'report' + ? reportBudget.totalLeftover + : rolloverBudget.totalBalance + } + type="financial" + style={{ + ...styles.smallText, + color: theme.formInputText, + textAlign: 'right', + fontWeight: '500', + }} + /> + </View> + </View> + <PullToRefresh onRefresh={onRefresh}> + {!editMode ? ( + // <ScrollView + // ref={el => (this.list = el)} + // keyboardShouldPersistTaps="always" + // refreshControl={refreshControl} + // style={{ backgroundColor: colors.n10 }} + // automaticallyAdjustContentInsets={false} + // > <View + data-testid="budget-table" style={{ - width: 90, - justifyContent: 'center', - alignItems: 'flex-end', + paddingBottom: MOBILE_NAV_HEIGHT, }} > - <Label title="BALANCE" style={{ color: theme.formInputText }} /> - <CellValue - binding={ - type === 'report' - ? reportBudget.totalLeftover - : rolloverBudget.totalBalance - } - type="financial" - style={{ - ...styles.smallText, - color: theme.formInputText, - textAlign: 'right', - fontWeight: '500', - }} + <BudgetGroups + type={type} + categoryGroups={categoryGroups} + showBudgetedCol={!showSpentColumn} + show3Cols={show3Cols} + showHiddenCategories={showHiddenCategories} + // gestures={gestures} + month={month} + editMode={editMode} + onEditGroup={onEditGroup} + onEditCategory={onEditCategory} + onSaveCategory={onSaveCategory} + onDeleteCategory={onDeleteCategory} + onAddCategory={onAddCategory} + onAddGroup={onAddGroup} + onSaveGroup={onSaveGroup} + onDeleteGroup={onDeleteGroup} + onReorderCategory={onReorderCategory} + onReorderGroup={onReorderGroup} + onOpenMonthActionMenu={onOpenMonthActionMenu} + onBudgetAction={onBudgetAction} + /> + </View> + ) : ( + // </ScrollView> + // <DragDrop> + // {({ + // dragging, + // onGestureEvent, + // onHandlerStateChange, + // scrollRef, + // onScroll + // }) => ( + <View data-testid="budget-table"> + <BudgetGroups + type={type} + categoryGroups={categoryGroups} + showBudgetedCol={!showSpentColumn} + show3Cols={show3Cols} + showHiddenCategories={showHiddenCategories} + // gestures={gestures} + editMode={editMode} + onEditGroup={onEditGroup} + onEditCategory={onEditCategory} + onSaveCategory={onSaveCategory} + onDeleteCategory={onDeleteCategory} + onAddCategory={onAddCategory} + onAddGroup={onAddGroup} + onSaveGroup={onSaveGroup} + onDeleteGroup={onDeleteGroup} + onReorderCategory={onReorderCategory} + onReorderGroup={onReorderGroup} + onOpenMonthActionMenu={onOpenMonthActionMenu} + onBudgetAction={onBudgetAction} /> </View> - </View> - <PullToRefresh onRefresh={onRefresh}> - {!editMode ? ( - // <ScrollView - // ref={el => (this.list = el)} - // keyboardShouldPersistTaps="always" - // refreshControl={refreshControl} - // style={{ backgroundColor: colors.n10 }} - // automaticallyAdjustContentInsets={false} - // > - <View - data-testid="budget-table" - style={{ - paddingBottom: MOBILE_NAV_HEIGHT, - }} - > - <BudgetGroups - type={type} - categoryGroups={categoryGroups} - showBudgetedCol={!showSpentColumn} - show3Cols={show3Cols} - showHiddenCategories={showHiddenCategories} - // gestures={gestures} - month={month} - editMode={editMode} - onEditGroup={onEditGroup} - onEditCategory={onEditCategory} - onSaveCategory={onSaveCategory} - onDeleteCategory={onDeleteCategory} - onAddCategory={onAddCategory} - onAddGroup={onAddGroup} - onSaveGroup={onSaveGroup} - onDeleteGroup={onDeleteGroup} - onReorderCategory={onReorderCategory} - onReorderGroup={onReorderGroup} - onOpenMonthActionMenu={onOpenMonthActionMenu} - onBudgetAction={onBudgetAction} - /> - </View> - ) : ( - // </ScrollView> - // <DragDrop> - // {({ - // dragging, - // onGestureEvent, - // onHandlerStateChange, - // scrollRef, - // onScroll - // }) => ( - <View data-testid="budget-table"> - <BudgetGroups - type={type} - categoryGroups={categoryGroups} - showBudgetedCol={!showSpentColumn} - show3Cols={show3Cols} - showHiddenCategories={showHiddenCategories} - // gestures={gestures} - editMode={editMode} - onEditGroup={onEditGroup} - onEditCategory={onEditCategory} - onSaveCategory={onSaveCategory} - onDeleteCategory={onDeleteCategory} - onAddCategory={onAddCategory} - onAddGroup={onAddGroup} - onSaveGroup={onSaveGroup} - onDeleteGroup={onDeleteGroup} - onReorderCategory={onReorderCategory} - onReorderGroup={onReorderGroup} - onOpenMonthActionMenu={onOpenMonthActionMenu} - onBudgetAction={onBudgetAction} - /> - </View> - // <DragDropHighlight /> - // </DragDrop> - )} - </PullToRefresh> - </Page> - </NamespaceContext.Provider> + // <DragDropHighlight /> + // </DragDrop> + )} + </PullToRefresh> + </Page> ); } diff --git a/packages/desktop-client/src/components/mobile/budget/index.tsx b/packages/desktop-client/src/components/mobile/budget/index.tsx index d221f00a50c8998c1eb41a3082d927774deb1b91..eb5e5a7fd377efc4fb5ec11c9422370e5098aafa 100644 --- a/packages/desktop-client/src/components/mobile/budget/index.tsx +++ b/packages/desktop-client/src/components/mobile/budget/index.tsx @@ -33,6 +33,7 @@ import { AnimatedLoading } from '../../../icons/AnimatedLoading'; import { theme } from '../../../style'; import { prewarmMonth, switchBudgetType } from '../../budget/util'; import { View } from '../../common/View'; +import { NamespaceContext } from '../../spreadsheet/NamespaceContext'; import { SyncRefresh } from '../../SyncRefresh'; import { BudgetTable } from './BudgetTable'; @@ -369,42 +370,44 @@ function BudgetInner(props: BudgetInnerProps) { } return ( - <SyncRefresh - onSync={async () => { - dispatch(sync()); - }} - > - {({ onRefresh }) => ( - <BudgetTable - // This key forces the whole table rerender when the number - // format changes - key={`${numberFormat}${hideFraction}`} - categoryGroups={categoryGroups} - type={budgetType} - month={currentMonth} - monthBounds={bounds} - editMode={editMode} - onEditMode={flag => setEditMode(flag)} - onShowBudgetSummary={onShowBudgetSummary} - onPrevMonth={onPrevMonth} - onNextMonth={onNextMonth} - onSaveGroup={onSaveGroup} - onDeleteGroup={onDeleteGroup} - onAddGroup={onAddGroup} - onAddCategory={onAddCategory} - onSaveCategory={onSaveCategory} - onDeleteCategory={onDeleteCategory} - onReorderCategory={onReorderCategory} - onReorderGroup={onReorderGroup} - onOpenMonthActionMenu={() => {}} //onOpenMonthActionMenu} - onBudgetAction={onBudgetAction} - onRefresh={onRefresh} - onSwitchBudgetType={onSwitchBudgetType} - onEditGroup={onEditGroup} - onEditCategory={onEditCategory} - /> - )} - </SyncRefresh> + <NamespaceContext.Provider value={monthUtils.sheetForMonth(currentMonth)}> + <SyncRefresh + onSync={async () => { + dispatch(sync()); + }} + > + {({ onRefresh }) => ( + <BudgetTable + // This key forces the whole table rerender when the number + // format changes + key={`${numberFormat}${hideFraction}`} + categoryGroups={categoryGroups} + type={budgetType} + month={currentMonth} + monthBounds={bounds} + editMode={editMode} + onEditMode={flag => setEditMode(flag)} + onShowBudgetSummary={onShowBudgetSummary} + onPrevMonth={onPrevMonth} + onNextMonth={onNextMonth} + onSaveGroup={onSaveGroup} + onDeleteGroup={onDeleteGroup} + onAddGroup={onAddGroup} + onAddCategory={onAddCategory} + onSaveCategory={onSaveCategory} + onDeleteCategory={onDeleteCategory} + onReorderCategory={onReorderCategory} + onReorderGroup={onReorderGroup} + onOpenMonthActionMenu={() => {}} //onOpenMonthActionMenu} + onBudgetAction={onBudgetAction} + onRefresh={onRefresh} + onSwitchBudgetType={onSwitchBudgetType} + onEditGroup={onEditGroup} + onEditCategory={onEditCategory} + /> + )} + </SyncRefresh> + </NamespaceContext.Provider> ); } diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index 9e9be907aa153f0fa5676b65c5e1736b84707acd..98b02cd0e9e96ade3870418dcfd34b1ce9e3af9d 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -428,6 +428,7 @@ const TransactionEditInner = memo(function TransactionEditInner({ [], [unserializedTransactions, dateFormat], ); + const { grouped: categoryGroups } = useCategories(); const [transaction, ...childTransactions] = transactions; @@ -542,24 +543,90 @@ const TransactionEditInner = memo(function TransactionEditInner({ }; const onClick = (transactionId, name) => { - onRequestActiveEdit?.(getFieldName(transaction.id, 'payee'), () => { - dispatch( - pushModal('edit-field', { - name, - onSubmit: (name, value) => { - const transaction = unserializedTransactions.find( - t => t.id === transactionId, - ); - // This is a deficiency of this API, need to fix. It - // assumes that it receives a serialized transaction, - // but we only have access to the raw transaction - onEdit(serializeTransaction(transaction, dateFormat), name, value); - }, - onClose: () => { - onClearActiveEdit(); - }, - }), + onRequestActiveEdit?.(getFieldName(transaction.id, name), () => { + const transaction = unserializedTransactions.find( + t => t.id === transactionId, ); + switch (name) { + case 'category': + dispatch( + pushModal('category-autocomplete', { + categoryGroups, + onSelect: categoryId => { + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit( + serializeTransaction(transaction, dateFormat), + name, + categoryId, + ); + }, + onClose: () => { + onClearActiveEdit(); + }, + }), + ); + break; + case 'account': + dispatch( + pushModal('account-autocomplete', { + onSelect: accountId => { + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit( + serializeTransaction(transaction, dateFormat), + name, + accountId, + ); + }, + onClose: () => { + onClearActiveEdit(); + }, + }), + ); + break; + case 'payee': + dispatch( + pushModal('payee-autocomplete', { + onSelect: payeeId => { + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit( + serializeTransaction(transaction, dateFormat), + name, + payeeId, + ); + }, + onClose: () => { + onClearActiveEdit(); + }, + }), + ); + break; + default: + dispatch( + pushModal('edit-field', { + name, + onSubmit: (name, value) => { + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit( + serializeTransaction(transaction, dateFormat), + name, + value, + ); + }, + onClose: () => { + onClearActiveEdit(); + }, + }), + ); + break; + } }); }; diff --git a/packages/desktop-client/src/components/modals/AccountAutocompleteModal.tsx b/packages/desktop-client/src/components/modals/AccountAutocompleteModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5ec7911bef17da12c446f3f6a642fdc46e962fa2 --- /dev/null +++ b/packages/desktop-client/src/components/modals/AccountAutocompleteModal.tsx @@ -0,0 +1,78 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { useResponsive } from '../../ResponsiveProvider'; +import { theme } from '../../style'; +import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { SectionLabel } from '../forms'; +import { type CommonModalProps } from '../Modals'; + +type AccountAutocompleteModalProps = { + modalProps: CommonModalProps; + autocompleteProps: ComponentPropsWithoutRef<typeof AccountAutocomplete>; + onClose: () => void; +}; + +export function AccountAutocompleteModal({ + modalProps, + autocompleteProps, + onClose, +}: AccountAutocompleteModalProps) { + const _onClose = () => { + modalProps.onClose(); + onClose?.(); + }; + + const { isNarrowWidth } = useResponsive(); + const defaultAutocompleteProps = { + containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, + }; + + return ( + <Modal + title="Account" + noAnimation={!isNarrowWidth} + showHeader={isNarrowWidth} + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 0, + height: isNarrowWidth ? '85vh' : 275, + padding: '15px 10px', + borderRadius: '6px', + ...(!isNarrowWidth && { + backgroundColor: theme.mobileModalBackground, + color: theme.mobileModalText, + }), + }} + > + {() => ( + <View> + {!isNarrowWidth && ( + <SectionLabel + title="Account" + style={{ + alignSelf: 'center', + color: theme.mobileModalText, + marginBottom: 10, + }} + /> + )} + <View style={{ flex: 1 }}> + <AccountAutocomplete + focused={true} + embedded={true} + closeOnBlur={false} + onClose={_onClose} + {...defaultAutocompleteProps} + {...autocompleteProps} + /> + </View> + </View> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/AccountMenuModal.tsx b/packages/desktop-client/src/components/modals/AccountMenuModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c67599891926ce5b51d272c449ba6fc24e061087 --- /dev/null +++ b/packages/desktop-client/src/components/modals/AccountMenuModal.tsx @@ -0,0 +1,250 @@ +import React, { useState } from 'react'; + +import { useLiveQuery } from 'loot-core/src/client/query-hooks'; +import { q } from 'loot-core/src/shared/query'; +import { type AccountEntity } from 'loot-core/types/models'; + +import { useAccounts } from '../../hooks/useAccounts'; +import { SvgClose, SvgDotsHorizontalTriple, SvgLockOpen } from '../../icons/v1'; +import { SvgNotesPaper } from '../../icons/v2'; +import { type CSSProperties, styles, theme } from '../../style'; +import { Button } from '../common/Button'; +import { Menu } from '../common/Menu'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { type CommonModalProps } from '../Modals'; +import { Notes } from '../Notes'; +import { Tooltip } from '../tooltips'; + +type NoteEntity = { + id: string; + note: string; +}; + +type AccountMenuModalProps = { + modalProps: CommonModalProps; + accountId: string; + onSave: (account: AccountEntity) => void; + onCloseAccount: (accountId: string) => void; + onReopenAccount: (accountId: string) => void; + onEditNotes: (id: string) => void; + onClose?: () => void; +}; + +export function AccountMenuModal({ + modalProps, + accountId, + onSave, + onCloseAccount, + onReopenAccount, + onEditNotes, + onClose, +}: AccountMenuModalProps) { + const accounts = useAccounts(); + const account = accounts.find(c => c.id === accountId); + const data = useLiveQuery( + () => q('notes').filter({ id: account?.id }).select('*'), + [account?.id], + ) as NoteEntity[] | null; + const originalNotes = data && data.length > 0 ? data[0].note : null; + + const _onClose = () => { + modalProps?.onClose(); + onClose?.(); + }; + + const onRename = (newName: string) => { + if (!account) { + return; + } + + if (newName !== account.name) { + onSave?.({ + ...account, + name: newName, + }); + } + }; + + const _onEditNotes = () => { + if (!account) { + return; + } + + onEditNotes?.(account.id); + }; + + const buttonStyle: CSSProperties = { + ...styles.mediumText, + height: styles.mobileMinHeight, + color: theme.formLabelText, + // Adjust based on desired number of buttons per row. + flexBasis: '100%', + }; + + if (!account) { + return null; + } + + return ( + <Modal + title={account.name} + titleStyle={styles.underlinedText} + showHeader + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 1, + height: '45vh', + padding: '0 10px', + borderRadius: '6px', + }} + editableTitle={true} + onTitleUpdate={onRename} + leftHeaderContent={ + <AdditionalAccountMenu + account={account} + onClose={onCloseAccount} + onReopen={onReopenAccount} + /> + } + > + {({ isEditingTitle }) => ( + <View + style={{ + flex: 1, + flexDirection: 'column', + }} + > + <View + style={{ + overflowY: 'auto', + flex: 1, + }} + > + <Notes + notes={ + originalNotes && originalNotes.length > 0 + ? originalNotes + : 'No notes' + } + editable={false} + focused={false} + getStyle={() => ({ + borderRadius: 6, + ...((!originalNotes || originalNotes.length === 0) && { + justifySelf: 'center', + alignSelf: 'center', + color: theme.pageTextSubdued, + }), + })} + /> + </View> + <View + style={{ + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignContent: 'space-between', + margin: '10px 0', + }} + > + <Button + style={{ + ...buttonStyle, + display: isEditingTitle ? 'none' : undefined, + }} + onClick={_onEditNotes} + > + <SvgNotesPaper + width={20} + height={20} + style={{ paddingRight: 5 }} + /> + Edit notes + </Button> + </View> + </View> + )} + </Modal> + ); +} + +type AdditionalAccountMenuProps = { + account: AccountEntity; + onClose?: (accountId: string) => void; + onReopen?: (accountId: string) => void; +}; + +function AdditionalAccountMenu({ + account, + onClose, + onReopen, +}: AdditionalAccountMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const itemStyle: CSSProperties = { + ...styles.mediumText, + height: styles.mobileMinHeight, + }; + + return ( + <View> + <Button + type="bare" + aria-label="Menu" + onClick={() => { + setMenuOpen(true); + }} + > + <SvgDotsHorizontalTriple + width={17} + height={17} + style={{ color: 'currentColor' }} + /> + {menuOpen && ( + <Tooltip + position="bottom-left" + style={{ padding: 0 }} + onClose={() => { + setMenuOpen(false); + }} + > + <Menu + getItemStyle={() => itemStyle} + items={[ + account.closed + ? { + name: 'reopen', + text: 'Reopen account', + icon: SvgLockOpen, + iconSize: 15, + } + : { + name: 'close', + text: 'Close account', + icon: SvgClose, + iconSize: 15, + }, + ]} + onMenuSelect={name => { + setMenuOpen(false); + switch (name) { + case 'close': + onClose?.(account.id); + break; + case 'reopen': + onReopen?.(account.id); + break; + default: + throw new Error(`Unrecognized menu option: ${name}`); + } + }} + /> + </Tooltip> + )} + </Button> + </View> + ); +} diff --git a/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx b/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d5c9f9722fac4106373a278aceb70271f141bcc --- /dev/null +++ b/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx @@ -0,0 +1,80 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { useResponsive } from '../../ResponsiveProvider'; +import { theme } from '../../style'; +import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { SectionLabel } from '../forms'; +import { type CommonModalProps } from '../Modals'; + +type CategoryAutocompleteModalProps = { + modalProps: CommonModalProps; + autocompleteProps: ComponentPropsWithoutRef<typeof CategoryAutocomplete>; + onClose: () => void; +}; + +export function CategoryAutocompleteModal({ + modalProps, + autocompleteProps, + onClose, +}: CategoryAutocompleteModalProps) { + const _onClose = () => { + modalProps.onClose(); + onClose?.(); + }; + + const { isNarrowWidth } = useResponsive(); + + const defaultAutocompleteProps = { + containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, + }; + + return ( + <Modal + title="Category" + noAnimation={!isNarrowWidth} + showHeader={isNarrowWidth} + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 0, + height: isNarrowWidth ? '85vh' : 275, + padding: '15px 10px', + borderRadius: '6px', + ...(!isNarrowWidth && { + backgroundColor: theme.mobileModalBackground, + color: theme.mobileModalText, + }), + }} + > + {() => ( + <View> + {!isNarrowWidth && ( + <SectionLabel + title="Category" + style={{ + alignSelf: 'center', + color: theme.mobileModalText, + marginBottom: 10, + }} + /> + )} + <View style={{ flex: 1 }}> + <CategoryAutocomplete + focused={true} + embedded={true} + closeOnBlur={false} + showSplitOption={false} + onClose={_onClose} + {...defaultAutocompleteProps} + {...autocompleteProps} + /> + </View> + </View> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx b/packages/desktop-client/src/components/modals/CategoryGroupMenuModal.tsx similarity index 92% rename from packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx rename to packages/desktop-client/src/components/modals/CategoryGroupMenuModal.tsx index 42e24a0b80980a0e68ce3b9a643667ddc509d019..c963e668fb8a13dc3f75e2ba87ce6ec640f230cc 100644 --- a/packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx +++ b/packages/desktop-client/src/components/modals/CategoryGroupMenuModal.tsx @@ -17,9 +17,7 @@ import { type CommonModalProps } from '../Modals'; import { Notes } from '../Notes'; import { Tooltip } from '../tooltips'; -const BUTTON_HEIGHT = 40; - -type CategoryGroupMenuProps = { +type CategoryGroupMenuModalProps = { modalProps: CommonModalProps; groupId: string; onSave: (group: CategoryGroupEntity) => void; @@ -30,7 +28,7 @@ type CategoryGroupMenuProps = { onClose?: () => void; }; -export function CategoryGroupMenu({ +export function CategoryGroupMenuModal({ modalProps, groupId, onSave, @@ -38,7 +36,7 @@ export function CategoryGroupMenu({ onEditNotes, onDelete, onClose, -}: CategoryGroupMenuProps) { +}: CategoryGroupMenuModalProps) { const { grouped: categoryGroups } = useCategories(); const group = categoryGroups.find(g => g.id === groupId); const data = useLiveQuery( @@ -47,47 +45,43 @@ export function CategoryGroupMenu({ ); const notes = data && data.length > 0 ? data[0].note : null; - function _onClose() { + const _onClose = () => { modalProps?.onClose(); onClose?.(); - } + }; - function _onRename(newName) { + const onRename = newName => { if (newName !== group.name) { onSave?.({ ...group, name: newName, }); } - } + }; - function _onAddCategory() { + const _onAddCategory = () => { onAddCategory?.(group.id, group.is_income); - } + }; - function _onEditNotes() { + const _onEditNotes = () => { onEditNotes?.(group.id); - } + }; - function _onToggleVisibility() { + const _onToggleVisibility = () => { onSave?.({ ...group, hidden: !!!group.hidden, }); _onClose(); - } + }; - function _onDelete() { + const _onDelete = () => { onDelete?.(group.id); - } - - function onNameUpdate(newName) { - _onRename(newName); - } + }; const buttonStyle: CSSProperties = { ...styles.mediumText, - height: BUTTON_HEIGHT, + height: styles.mobileMinHeight, color: theme.formLabelText, // Adjust based on desired number of buttons per row. flexBasis: '48%', @@ -111,7 +105,7 @@ export function CategoryGroupMenu({ }} editableTitle={true} titleStyle={styles.underlinedText} - onTitleUpdate={onNameUpdate} + onTitleUpdate={onRename} leftHeaderContent={ <AdditionalCategoryGroupMenu group={group} @@ -194,7 +188,7 @@ function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { const [menuOpen, setMenuOpen] = useState(false); const itemStyle: CSSProperties = { ...styles.mediumText, - height: BUTTON_HEIGHT, + height: styles.mobileMinHeight, }; return ( @@ -224,6 +218,7 @@ function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { ...styles.mediumText, color: theme.formLabelText, }} + getItemStyle={() => itemStyle} items={ [ { @@ -231,7 +226,6 @@ function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { text: group.hidden ? 'Show' : 'Hide', icon: group.hidden ? SvgViewShow : SvgViewHide, iconSize: 16, - style: itemStyle, }, ...(!group.is_income && [ Menu.line, @@ -240,7 +234,6 @@ function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { text: 'Delete', icon: SvgTrash, iconSize: 15, - style: itemStyle, }, ]), ].filter(i => i != null) as ComponentProps<typeof Menu>['items'] diff --git a/packages/desktop-client/src/components/modals/CategoryMenu.tsx b/packages/desktop-client/src/components/modals/CategoryMenuModal.tsx similarity index 91% rename from packages/desktop-client/src/components/modals/CategoryMenu.tsx rename to packages/desktop-client/src/components/modals/CategoryMenuModal.tsx index 5d2c704f88ab49ae3c4469d19aca6b8b80889bef..a67c1188957223d5950dc4becdf7607ac1d1d408 100644 --- a/packages/desktop-client/src/components/modals/CategoryMenu.tsx +++ b/packages/desktop-client/src/components/modals/CategoryMenuModal.tsx @@ -17,9 +17,7 @@ import { type CommonModalProps } from '../Modals'; import { Notes } from '../Notes'; import { Tooltip } from '../tooltips'; -const BUTTON_HEIGHT = 40; - -type CategoryMenuProps = { +type CategoryMenuModalProps = { modalProps: CommonModalProps; categoryId: string; onSave: (category: CategoryEntity) => void; @@ -28,14 +26,14 @@ type CategoryMenuProps = { onClose?: () => void; }; -export function CategoryMenu({ +export function CategoryMenuModal({ modalProps, categoryId, onSave, onEditNotes, onDelete, onClose, -}: CategoryMenuProps) { +}: CategoryMenuModalProps) { const { list: categories } = useCategories(); const category = categories.find(c => c.id === categoryId); const data = useLiveQuery( @@ -44,43 +42,39 @@ export function CategoryMenu({ ); const originalNotes = data && data.length > 0 ? data[0].note : null; - function _onClose() { + const _onClose = () => { modalProps?.onClose(); onClose?.(); - } + }; - function _onRename(newName) { + const onRename = newName => { if (newName !== category.name) { onSave?.({ ...category, name: newName, }); } - } + }; - function _onToggleVisibility() { + const _onToggleVisibility = () => { onSave?.({ ...category, hidden: !category.hidden, }); _onClose(); - } + }; - function _onEditNotes() { + const _onEditNotes = () => { onEditNotes?.(category.id); - } + }; - function _onDelete() { + const _onDelete = () => { onDelete?.(category.id); - } - - function onNameUpdate(newName) { - _onRename(newName); - } + }; const buttonStyle: CSSProperties = { ...styles.mediumText, - height: BUTTON_HEIGHT, + height: styles.mobileMinHeight, color: theme.formLabelText, // Adjust based on desired number of buttons per row. flexBasis: '100%', @@ -102,7 +96,7 @@ export function CategoryMenu({ borderRadius: '6px', }} editableTitle={true} - onTitleUpdate={onNameUpdate} + onTitleUpdate={onRename} leftHeaderContent={ <AdditionalCategoryMenu category={category} @@ -172,7 +166,7 @@ function AdditionalCategoryMenu({ category, onDelete, onToggleVisibility }) { const [menuOpen, setMenuOpen] = useState(false); const itemStyle: CSSProperties = { ...styles.mediumText, - height: BUTTON_HEIGHT, + height: styles.mobileMinHeight, }; return ( @@ -198,13 +192,13 @@ function AdditionalCategoryMenu({ category, onDelete, onToggleVisibility }) { }} > <Menu + getItemStyle={() => itemStyle} items={[ { name: 'toggleVisibility', text: category.hidden ? 'Show' : 'Hide', icon: category.hidden ? SvgViewShow : SvgViewHide, iconSize: 16, - style: itemStyle, }, Menu.line, { @@ -212,7 +206,6 @@ function AdditionalCategoryMenu({ category, onDelete, onToggleVisibility }) { text: 'Delete', icon: SvgTrash, iconSize: 15, - style: itemStyle, }, ]} onMenuSelect={itemName => { diff --git a/packages/desktop-client/src/components/modals/CloseAccount.tsx b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx similarity index 57% rename from packages/desktop-client/src/components/modals/CloseAccount.tsx rename to packages/desktop-client/src/components/modals/CloseAccountModal.tsx index afac51f8a1e839e69cda778be05d0b9339487233..aa71110c21ed367bb39c1fc8a0b7ade7484b845b 100644 --- a/packages/desktop-client/src/components/modals/CloseAccount.tsx +++ b/packages/desktop-client/src/components/modals/CloseAccountModal.tsx @@ -1,13 +1,19 @@ // @ts-strict-ignore import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + closeAccount, + forceCloseAccount, + pushModal, +} from 'loot-core/client/actions'; import { integerToCurrency } from 'loot-core/src/shared/util'; import { type AccountEntity } from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; -import { type BoundActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; -import { theme } from '../../style'; +import { useResponsive } from '../../ResponsiveProvider'; +import { type CSSProperties, styles, theme } from '../../style'; import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete'; import { Button } from '../common/Button'; @@ -32,29 +38,53 @@ function needsCategory( return account.offbudget === 0 && isOffBudget; } -type CloseAccountProps = { +type CloseAccountModalProps = { account: AccountEntity; balance: number; canDelete: boolean; - actions: BoundActions; modalProps: CommonModalProps; }; -export function CloseAccount({ +export function CloseAccountModal({ account, balance, canDelete, - actions, modalProps, -}: CloseAccountProps) { +}: CloseAccountModalProps) { + const accounts = useAccounts().filter(a => a.closed === 0); + const { grouped: categoryGroups, list: categories } = useCategories(); const [loading, setLoading] = useState(false); - const [transfer, setTransfer] = useState(''); - const [category, setCategory] = useState(''); + const [transferAccountId, setTransferAccountId] = useState(''); + const transferAccount = accounts.find(a => a.id === transferAccountId); + const [categoryId, setCategoryId] = useState(''); + const category = categories.find(c => c.id === categoryId); const [transferError, setTransferError] = useState(false); const [categoryError, setCategoryError] = useState(false); - const accounts = useAccounts().filter(a => a.closed === 0); - const { grouped: categoryGroups } = useCategories(); + const dispatch = useDispatch(); + const { isNarrowWidth } = useResponsive(); + + const onSelectAccount = accId => { + setTransferAccountId(accId); + if (transferError && accId) { + setTransferError(false); + } + }; + + const onSelectCategory = catId => { + setCategoryId(catId); + if (categoryError && catId) { + setCategoryError(false); + } + }; + + const narrowStyle: CSSProperties = isNarrowWidth + ? { + userSelect: 'none', + height: styles.mobileMinHeight, + ...styles.mediumText, + } + : {}; return ( <Modal @@ -82,21 +112,25 @@ export function CloseAccount({ onSubmit={event => { event.preventDefault(); - const transferError = balance !== 0 && transfer === ''; + const transferError = balance !== 0 && transferAccountId === ''; setTransferError(transferError); const categoryError = - needsCategory(account, transfer, accounts) && category === ''; + needsCategory(account, transferAccountId, accounts) && + categoryId === ''; setCategoryError(categoryError); if (!transferError && !categoryError) { setLoading(true); - actions - .closeAccount(account.id, transfer || null, category || null) - .then(() => { - modalProps.onClose(); - }); + dispatch( + closeAccount( + account.id, + transferAccountId || null, + categoryId || null, + ), + ); + modalProps.onClose(); } }} > @@ -112,16 +146,25 @@ export function CloseAccount({ <View style={{ marginBottom: 15 }}> <AccountAutocomplete includeClosedAccounts={false} - value={transfer} + value={transferAccountId} inputProps={{ placeholder: 'Select account...', + ...(isNarrowWidth && { + value: transferAccount?.name || '', + style: { + ...narrowStyle, + }, + onClick: () => { + dispatch( + pushModal('account-autocomplete', { + includeClosedAccounts: false, + onSelect: onSelectAccount, + }), + ); + }, + }), }} - onSelect={acc => { - setTransfer(acc); - if (transferError && acc) { - setTransferError(false); - } - }} + onSelect={onSelectAccount} /> </View> @@ -131,7 +174,7 @@ export function CloseAccount({ </FormError> )} - {needsCategory(account, transfer, accounts) && ( + {needsCategory(account, transferAccountId, accounts) && ( <View style={{ marginBottom: 15 }}> <Paragraph> Since you are transferring the balance from a budgeted @@ -141,17 +184,26 @@ export function CloseAccount({ <CategoryAutocomplete categoryGroups={categoryGroups} - value={category} + value={categoryId} inputProps={{ placeholder: 'Select category...', + ...(isNarrowWidth && { + value: category?.name || '', + style: { + ...narrowStyle, + }, + onClick: () => { + dispatch( + pushModal('category-autocomplete', { + categoryGroups, + showHiddenCategories: true, + onSelect: onSelectCategory, + }), + ); + }, + }), }} - onSelect={newValue => { - setCategory(newValue); - if (categoryError && newValue) { - setCategoryError(false); - } - }} - showHiddenCategories={true} + onSelect={onSelectCategory} /> {categoryError && ( @@ -170,9 +222,8 @@ export function CloseAccount({ onClick={() => { setLoading(true); - actions - .forceCloseAccount(account.id) - .then(() => modalProps.onClose()); + dispatch(forceCloseAccount(account.id)); + modalProps.onClose(); }} style={{ color: theme.errorText }} > @@ -191,10 +242,23 @@ export function CloseAccount({ justifyContent: 'flex-end', }} > - <Button style={{ marginRight: 10 }} onClick={modalProps.onClose}> + <Button + style={{ + marginRight: 10, + height: isNarrowWidth ? styles.mobileMinHeight : undefined, + }} + onClick={modalProps.onClose} + > Cancel </Button> - <Button type="primary">Close Account</Button> + <Button + type="primary" + style={{ + height: isNarrowWidth ? styles.mobileMinHeight : undefined, + }} + > + Close Account + </Button> </View> </form> </View> diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..90f4369da98de628b0c3f8aa428f0fccc721263e --- /dev/null +++ b/packages/desktop-client/src/components/modals/CoverModal.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { pushModal } from 'loot-core/client/actions'; + +import { useCategories } from '../../hooks/useCategories'; +import { useInitialMount } from '../../hooks/useInitialMount'; +import { styles } from '../../style'; +import { addToBeBudgetedGroup } from '../budget/util'; +import { Button } from '../common/Button'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { FieldLabel, TapField } from '../mobile/MobileForms'; +import { type CommonModalProps } from '../Modals'; + +type CoverModalProps = { + modalProps: CommonModalProps; + categoryId: string; + onSubmit: (categoryId: string) => void; +}; + +export function CoverModal({ + modalProps, + categoryId, + onSubmit, +}: CoverModalProps) { + const { grouped: originalCategoryGroups, list: categories } = useCategories(); + const categoryGroups = addToBeBudgetedGroup( + originalCategoryGroups.filter(g => !g.is_income), + ); + + const [fromCategoryId, setFromCategoryId] = useState<string | null>(null); + const dispatch = useDispatch(); + + const onCategoryClick = useCallback(() => { + dispatch( + pushModal('category-autocomplete', { + categoryGroups, + onSelect: categoryId => { + setFromCategoryId(categoryId); + }, + }), + ); + }, [categoryGroups, dispatch]); + + const _onSubmit = (categoryId: string | null) => { + if (categoryId) { + onSubmit?.(categoryId); + } + + modalProps.onClose(); + }; + + const initialMount = useInitialMount(); + + useEffect(() => { + if (initialMount) { + onCategoryClick(); + } + }, [initialMount, onCategoryClick]); + + const category = categories.find(c => c.id === categoryId); + + if (!category) { + return null; + } + + return ( + <Modal + title={`Cover: ${category.name}`} + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <> + <View> + <FieldLabel title="Cover from category:" /> + <TapField + value={categories.find(c => c.id === fromCategoryId)?.name} + onClick={onCategoryClick} + /> + </View> + + <View + style={{ + justifyContent: 'center', + alignItems: 'center', + marginTop: 10, + }} + > + <Button + type="primary" + style={{ + height: styles.mobileMinHeight, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + }} + onClick={() => _onSubmit(fromCategoryId)} + > + Transfer + </Button> + </View> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/EditField.jsx b/packages/desktop-client/src/components/modals/EditField.jsx index 52f1bb7912533697ad9772533cc9d89260c0d32d..a4822faf32fbc2dfa5f1f5b624d993b46659cd18 100644 --- a/packages/desktop-client/src/components/modals/EditField.jsx +++ b/packages/desktop-client/src/components/modals/EditField.jsx @@ -5,28 +5,10 @@ import { parseISO, format as formatDate, parse as parseDate } from 'date-fns'; import { currentDay, dayFromDate } from 'loot-core/src/shared/months'; import { amountToInteger } from 'loot-core/src/shared/util'; -import { useAccounts } from '../../hooks/useAccounts'; -import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; import { useDateFormat } from '../../hooks/useDateFormat'; -import { usePayees } from '../../hooks/usePayees'; -import { SvgAdd } from '../../icons/v1'; import { useResponsive } from '../../ResponsiveProvider'; -import { styles, theme } from '../../style'; -import { - AccountAutocomplete, - AccountItem, -} from '../autocomplete/AccountAutocomplete'; -import { - CategoryAutocomplete, - CategoryItem, -} from '../autocomplete/CategoryAutocomplete'; -import { ItemHeader } from '../autocomplete/ItemHeader'; -import { - PayeeAutocomplete, - CreatePayeeButton, - PayeeItem, -} from '../autocomplete/PayeeAutocomplete'; +import { theme } from '../../style'; import { Button } from '../common/Button'; import { Input } from '../common/Input'; import { Modal } from '../common/Modal'; @@ -34,17 +16,13 @@ import { View } from '../common/View'; import { SectionLabel } from '../forms'; import { DateSelect } from '../select/DateSelect'; -function CreatePayeeIcon(props) { - return <SvgAdd {...props} width={14} height={14} />; -} +import { AccountAutocompleteModal } from './AccountAutocompleteModal'; +import { CategoryAutocompleteModal } from './CategoryAutocompleteModal'; +import { PayeeAutocompleteModal } from './PayeeAutocompleteModal'; export function EditField({ modalProps, name, onSubmit, onClose }) { const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const { grouped: categoryGroups } = useCategories(); - const accounts = useAccounts(); - const payees = usePayees(); - - const { createPayee } = useActions(); const onCloseInner = () => { modalProps.onClose(); onClose?.(); @@ -82,15 +60,11 @@ export function EditField({ modalProps, name, onSubmit, onClose }) { ':focus': { boxShadow: 0 }, ...(isNarrowWidth && itemStyle), }; - const autocompleteProps = { - inputProps: { style: inputStyle }, - containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, - }; const [noteAmend, onChangeMode] = useState('replace'); switch (name) { - case 'date': { + case 'date': const today = currentDay(); label = 'Date'; minWidth = 350; @@ -107,105 +81,50 @@ export function EditField({ modalProps, name, onSubmit, onClose }) { /> ); break; - } - case 'account': - label = 'Account'; - editor = ( - <AccountAutocomplete - value={null} - accounts={accounts} - focused={true} - embedded={true} - closeOnBlur={false} - onSelect={value => { - if (value) { - onSelect(value); - } + case 'category': + return ( + <CategoryAutocompleteModal + modalProps={modalProps} + autocompleteProps={{ + categoryGroups, + showHiddenCategories: false, + value: null, + onSelect: categoryId => { + onSelect(categoryId); + }, }} - {...(isNarrowWidth && { - renderAccountItemGroupHeader: props => ( - <ItemHeader - {...props} - style={{ - ...styles.largeText, - color: theme.menuItemTextHeader, - paddingTop: 10, - paddingBottom: 10, - }} - /> - ), - renderAccountItem: props => ( - <AccountItem - {...props} - style={{ - ...itemStyle, - color: theme.menuItemText, - borderRadius: 0, - borderTop: `1px solid ${theme.pillBorder}`, - }} - /> - ), - })} - {...autocompleteProps} + onClose={onClose} /> ); - break; case 'payee': - label = 'Payee'; - editor = ( - <PayeeAutocomplete - payees={payees} - accounts={accounts} - value={null} - focused={true} - embedded={true} - closeOnBlur={false} - showManagePayees={false} - showMakeTransfer={!isNarrowWidth} - onSelect={async value => { - if (value && value.startsWith('new:')) { - value = await createPayee(value.slice('new:'.length)); - } + return ( + <PayeeAutocompleteModal + modalProps={modalProps} + autocompleteProps={{ + value: null, + onSelect: payeeId => { + onSelect(payeeId); + }, + }} + onClose={onClose} + /> + ); - onSelect(value); + case 'account': + return ( + <AccountAutocompleteModal + modalProps={modalProps} + autocompleteProps={{ + value: null, + onSelect: accountId => { + onSelect(accountId); + }, }} - {...(isNarrowWidth && { - renderCreatePayeeButton: props => ( - <CreatePayeeButton - {...props} - Icon={CreatePayeeIcon} - style={itemStyle} - /> - ), - renderPayeeItemGroupHeader: props => ( - <ItemHeader - {...props} - style={{ - ...styles.largeText, - color: theme.menuItemTextHeader, - paddingTop: 10, - paddingBottom: 10, - }} - /> - ), - renderPayeeItem: props => ( - <PayeeItem - {...props} - style={{ - ...itemStyle, - color: theme.menuItemText, - borderRadius: 0, - borderTop: `1px solid ${theme.pillBorder}`, - }} - /> - ), - })} - {...autocompleteProps} + onClose={onClose} /> ); - break; case 'notes': label = 'Notes'; @@ -330,50 +249,6 @@ export function EditField({ modalProps, name, onSubmit, onClose }) { ); break; - case 'category': - label = 'Category'; - editor = ( - <CategoryAutocomplete - categoryGroups={categoryGroups} - value={null} - focused={true} - embedded={true} - closeOnBlur={false} - showSplitOption={false} - onUpdate={() => {}} - onSelect={value => { - onSelect(value); - }} - {...(isNarrowWidth && { - renderCategoryItemGroupHeader: props => ( - <ItemHeader - {...props} - style={{ - ...styles.largeText, - color: theme.menuItemTextHeader, - paddingTop: 10, - paddingBottom: 10, - }} - /> - ), - renderCategoryItem: props => ( - <CategoryItem - {...props} - style={{ - ...itemStyle, - color: theme.menuItemText, - borderRadius: 0, - borderTop: `1px solid ${theme.pillBorder}`, - }} - /> - ), - })} - {...autocompleteProps} - showHiddenItems={false} - /> - ); - break; - case 'amount': label = 'Amount'; editor = ( diff --git a/packages/desktop-client/src/components/modals/HoldBufferModal.tsx b/packages/desktop-client/src/components/modals/HoldBufferModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74ea959aae54c9bd866dfe33eff82eb589f8a598 --- /dev/null +++ b/packages/desktop-client/src/components/modals/HoldBufferModal.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; + +import { rolloverBudget } from 'loot-core/client/queries'; +import { evalArithmetic } from 'loot-core/shared/arithmetic'; +import { amountToInteger, integerToCurrency } from 'loot-core/shared/util'; + +import { styles } from '../../style'; +import { Button } from '../common/Button'; +import { InitialFocus } from '../common/InitialFocus'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { FieldLabel, InputField } from '../mobile/MobileForms'; +import { type CommonModalProps } from '../Modals'; +import { useSheetValue } from '../spreadsheet/useSheetValue'; + +type HoldBufferModalProps = { + modalProps: CommonModalProps; + month: string; + onSubmit: (amount: number) => void; +}; + +export function HoldBufferModal({ + modalProps, + onSubmit, +}: HoldBufferModalProps) { + const available = useSheetValue(rolloverBudget.toBudget); + const initialAmount = integerToCurrency(Math.max(available, 0)); + const [amount, setAmount] = useState<string | null>(null); + + const _onSubmit = (newAmount: string | null) => { + const parsedAmount = evalArithmetic(newAmount || ''); + if (parsedAmount) { + onSubmit?.(amountToInteger(parsedAmount)); + } + + modalProps.onClose(); + }; + + return ( + <Modal + title="Hold Buffer" + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <> + <View> + <FieldLabel title="Hold this amount:" /> + <InitialFocus> + <InputField + defaultValue={initialAmount} + onUpdate={value => setAmount(value)} + onEnter={() => _onSubmit(amount)} + /> + </InitialFocus> + </View> + <View + style={{ + justifyContent: 'center', + alignItems: 'center', + marginTop: 10, + }} + > + <Button + type="primary" + style={{ + height: styles.mobileMinHeight, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + }} + onClick={() => _onSubmit(amount)} + > + Hold + </Button> + </View> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx b/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6eaf1a5a58b849b4d9ea879dee932e51b9a99905 --- /dev/null +++ b/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx @@ -0,0 +1,71 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { useAccounts } from '../../hooks/useAccounts'; +import { usePayees } from '../../hooks/usePayees'; +import { useResponsive } from '../../ResponsiveProvider'; +import { theme } from '../../style'; +import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; + +type PayeeAutocompleteModalProps = { + modalProps: CommonModalProps; + autocompleteProps: ComponentPropsWithoutRef<typeof PayeeAutocomplete>; + onClose: () => void; +}; + +export function PayeeAutocompleteModal({ + modalProps, + autocompleteProps, + onClose, +}: PayeeAutocompleteModalProps) { + const payees = usePayees() || []; + const accounts = useAccounts() || []; + + const _onClose = () => { + modalProps.onClose(); + onClose?.(); + }; + + const { isNarrowWidth } = useResponsive(); + const defaultAutocompleteProps = { + containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, + }; + + return ( + <Modal + title="Payee" + noAnimation={!isNarrowWidth} + showHeader={isNarrowWidth} + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 0, + height: isNarrowWidth ? '85vh' : 275, + padding: '15px 10px', + borderRadius: '6px', + ...(!isNarrowWidth && { + backgroundColor: theme.mobileModalBackground, + color: theme.mobileModalText, + }), + }} + > + {() => ( + <PayeeAutocomplete + payees={payees} + accounts={accounts} + focused={true} + embedded={true} + closeOnBlur={false} + onClose={_onClose} + showManagePayees={false} + showMakeTransfer={!isNarrowWidth} + {...defaultAutocompleteProps} + {...autocompleteProps} + /> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/ReportBalanceMenuModal.tsx b/packages/desktop-client/src/components/modals/ReportBalanceMenuModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c4d5e6e1eada91c9fba9df240502a0e0632b1b2 --- /dev/null +++ b/packages/desktop-client/src/components/modals/ReportBalanceMenuModal.tsx @@ -0,0 +1,49 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { type CSSProperties, theme, styles } from '../../style'; +import { BalanceMenu } from '../budget/report/BalanceMenu'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; + +type ReportBalanceMenuModalProps = ComponentPropsWithoutRef< + typeof BalanceMenu +> & { + modalProps: CommonModalProps; +}; + +export function ReportBalanceMenuModal({ + modalProps, + categoryId, + onCarryover, +}: ReportBalanceMenuModalProps) { + const defaultMenuItemStyle: CSSProperties = { + ...styles.mobileMenuItem, + color: theme.menuItemText, + borderRadius: 0, + borderTop: `1px solid ${theme.pillBorder}`, + }; + + return ( + <Modal + title="Actions" + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <BalanceMenu + categoryId={categoryId} + getItemStyle={() => defaultMenuItemStyle} + onCarryover={onCarryover} + /> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/ReportBudgetSummary.tsx b/packages/desktop-client/src/components/modals/ReportBudgetSummaryModal.tsx similarity index 92% rename from packages/desktop-client/src/components/modals/ReportBudgetSummary.tsx rename to packages/desktop-client/src/components/modals/ReportBudgetSummaryModal.tsx index d16acc205411bf4f13ecb6c796d2ce77152080c5..a58338ce3b2e8623c213073332c22b2056721fd6 100644 --- a/packages/desktop-client/src/components/modals/ReportBudgetSummary.tsx +++ b/packages/desktop-client/src/components/modals/ReportBudgetSummaryModal.tsx @@ -12,15 +12,15 @@ import { Stack } from '../common/Stack'; import { type CommonModalProps } from '../Modals'; import { NamespaceContext } from '../spreadsheet/NamespaceContext'; -type ReportBudgetSummaryProps = { +type ReportBudgetSummaryModalProps = { modalProps: CommonModalProps; month: string; }; -export function ReportBudgetSummary({ +export function ReportBudgetSummaryModal({ month, modalProps, -}: ReportBudgetSummaryProps) { +}: ReportBudgetSummaryModalProps) { const currentMonth = monthUtils.currentMonth(); return ( <Modal title="Budget Summary" {...modalProps}> diff --git a/packages/desktop-client/src/components/modals/RolloverBalanceMenuModal.tsx b/packages/desktop-client/src/components/modals/RolloverBalanceMenuModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75c4159103f0ef8903f06c6bbe1b569e10ae6090 --- /dev/null +++ b/packages/desktop-client/src/components/modals/RolloverBalanceMenuModal.tsx @@ -0,0 +1,53 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { type CSSProperties, theme, styles } from '../../style'; +import { BalanceMenu } from '../budget/rollover/BalanceMenu'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; + +type RolloverBalanceMenuModalProps = ComponentPropsWithoutRef< + typeof BalanceMenu +> & { + modalProps: CommonModalProps; +}; + +export function RolloverBalanceMenuModal({ + modalProps, + categoryId, + onCarryover, + onTransfer, + onCover, +}: RolloverBalanceMenuModalProps) { + const defaultMenuItemStyle: CSSProperties = { + ...styles.mobileMenuItem, + color: theme.menuItemText, + borderRadius: 0, + borderTop: `1px solid ${theme.pillBorder}`, + }; + + return ( + <Modal + title="Actions" + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <BalanceMenu + categoryId={categoryId} + getItemStyle={() => defaultMenuItemStyle} + onCarryover={onCarryover} + onTransfer={onTransfer} + onCover={onCover} + /> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummary.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummary.tsx deleted file mode 100644 index 58a0e4260cdba149fd11fddd6fdc8c78f2d5ef87..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/modals/RolloverBudgetSummary.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; - -import { format, sheetForMonth, prevMonth } from 'loot-core/src/shared/months'; - -import { styles } from '../../style'; -import { ToBudget } from '../budget/rollover/budgetsummary/ToBudget'; -import { TotalsList } from '../budget/rollover/budgetsummary/TotalsList'; -import { Modal } from '../common/Modal'; -import { type CommonModalProps } from '../Modals'; -import { NamespaceContext } from '../spreadsheet/NamespaceContext'; - -type RolloverBudgetSummaryProps = { - modalProps: CommonModalProps; - onBudgetAction: (idx: string | number, action: string, arg: unknown) => void; - month: string; -}; - -export function RolloverBudgetSummary({ - month, - onBudgetAction, - modalProps, -}: RolloverBudgetSummaryProps) { - const prevMonthName = format(prevMonth(month), 'MMM'); - - return ( - <Modal title="Budget Summary" {...modalProps}> - {() => ( - <NamespaceContext.Provider value={sheetForMonth(month)}> - <TotalsList - prevMonthName={prevMonthName} - style={{ - ...styles.mediumText, - }} - /> - <ToBudget - month={month} - prevMonthName={prevMonthName} - onBudgetAction={onBudgetAction} - style={{ - ...styles.mediumText, - marginTop: 15, - }} - amountStyle={{ - ...styles.underlinedText, - }} - totalsTooltipProps={{ - position: 'bottom-center', - }} - holdTooltipProps={{ - position: 'bottom-center', - }} - transferTooltipProps={{ - position: 'bottom-center', - }} - /> - </NamespaceContext.Provider> - )} - </Modal> - ); -} diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2839c3ee666583842099e18797f7884c18033604 --- /dev/null +++ b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { collapseModals, pushModal } from 'loot-core/client/actions'; +import { rolloverBudget } from 'loot-core/client/queries'; +import { format, sheetForMonth, prevMonth } from 'loot-core/src/shared/months'; + +import { styles } from '../../style'; +import { ToBudgetAmount } from '../budget/rollover/budgetsummary/ToBudgetAmount'; +import { TotalsList } from '../budget/rollover/budgetsummary/TotalsList'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; +import { NamespaceContext } from '../spreadsheet/NamespaceContext'; +import { useSheetValue } from '../spreadsheet/useSheetValue'; + +type RolloverBudgetSummaryModalProps = { + modalProps: CommonModalProps; + onBudgetAction: (idx: string | number, action: string, arg?: unknown) => void; + month: string; +}; + +export function RolloverBudgetSummaryModal({ + month, + onBudgetAction, + modalProps, +}: RolloverBudgetSummaryModalProps) { + const dispatch = useDispatch(); + const prevMonthName = format(prevMonth(month), 'MMM'); + const sheetValue = useSheetValue({ + name: rolloverBudget.toBudget, + value: 0, + }); + + const openTransferModal = () => { + dispatch( + pushModal('transfer', { + title: 'Transfer', + amount: sheetValue, + onSubmit: (amount, toCategoryId) => { + onBudgetAction?.(month, 'transfer-available', { + amount, + month, + category: toCategoryId, + }); + dispatch(collapseModals('transfer')); + }, + }), + ); + }; + + const onHoldBuffer = () => { + dispatch( + pushModal('hold-buffer', { + month, + onSubmit: amount => { + onBudgetAction(month, 'hold', { amount }); + dispatch(collapseModals('hold-buffer')); + }, + }), + ); + }; + + const onResetHoldBuffer = () => { + onBudgetAction?.(month, 'reset-hold'); + modalProps.onClose(); + }; + + const onClick = () => { + dispatch( + pushModal('rollover-to-budget-menu', { + month, + onTransfer: openTransferModal, + onResetHoldBuffer, + onHoldBuffer, + }), + ); + }; + + return ( + <Modal title="Budget Summary" {...modalProps}> + {() => ( + <NamespaceContext.Provider value={sheetForMonth(month)}> + <TotalsList + prevMonthName={prevMonthName} + style={{ + ...styles.mediumText, + }} + /> + <ToBudgetAmount + prevMonthName={prevMonthName} + style={{ + ...styles.mediumText, + marginTop: 15, + }} + amountStyle={{ + ...styles.underlinedText, + }} + onClick={onClick} + /> + </NamespaceContext.Provider> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da62d53d08f696d4e9a5844517d68bcc9f0a3a2c --- /dev/null +++ b/packages/desktop-client/src/components/modals/RolloverToBudgetMenuModal.tsx @@ -0,0 +1,51 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { type CSSProperties, theme, styles } from '../../style'; +import { ToBudgetMenu } from '../budget/rollover/budgetsummary/ToBudgetMenu'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; + +type RolloverToBudgetMenuModalProps = ComponentPropsWithoutRef< + typeof ToBudgetMenu +> & { + modalProps: CommonModalProps; +}; + +export function RolloverToBudgetMenuModal({ + modalProps, + onTransfer, + onHoldBuffer, + onResetHoldBuffer, +}: RolloverToBudgetMenuModalProps) { + const defaultMenuItemStyle: CSSProperties = { + ...styles.mobileMenuItem, + color: theme.menuItemText, + borderRadius: 0, + borderTop: `1px solid ${theme.pillBorder}`, + }; + + return ( + <Modal + title="Actions" + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <ToBudgetMenu + getItemStyle={() => defaultMenuItemStyle} + onTransfer={onTransfer} + onHoldBuffer={onHoldBuffer} + onResetHoldBuffer={onResetHoldBuffer} + /> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cfadec05dab4e74ebec8f14455330a925129ea52 --- /dev/null +++ b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx @@ -0,0 +1,93 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { type CSSProperties, theme, styles } from '../../style'; +import { Menu } from '../common/Menu'; +import { Modal } from '../common/Modal'; +import { type CommonModalProps } from '../Modals'; + +type ScheduledTransactionMenuModalProps = ScheduledTransactionMenuProps & { + modalProps: CommonModalProps; +}; + +export function ScheduledTransactionMenuModal({ + modalProps, + transactionId, + onSkip, + onPost, +}: ScheduledTransactionMenuModalProps) { + const defaultMenuItemStyle: CSSProperties = { + ...styles.mobileMenuItem, + color: theme.menuItemText, + borderRadius: 0, + borderTop: `1px solid ${theme.pillBorder}`, + }; + + return ( + <Modal + title="Actions" + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <ScheduledTransactionMenu + transactionId={transactionId} + onPost={onPost} + onSkip={onSkip} + getItemStyle={() => defaultMenuItemStyle} + /> + )} + </Modal> + ); +} + +type ScheduledTransactionMenuProps = Omit< + ComponentPropsWithoutRef<typeof Menu>, + 'onMenuSelect' | 'items' +> & { + transactionId: string; + onSkip: (transactionId: string) => void; + onPost: (transactionId: string) => void; +}; + +function ScheduledTransactionMenu({ + transactionId, + onSkip, + onPost, + ...props +}: ScheduledTransactionMenuProps) { + return ( + <Menu + {...props} + onMenuSelect={name => { + switch (name) { + case 'post': + onPost?.(transactionId); + break; + case 'skip': + onSkip?.(transactionId); + break; + default: + throw new Error(`Unrecognized menu option: ${name}`); + } + }} + items={[ + { + name: 'post', + text: 'Post transaction', + }, + { + name: 'skip', + text: 'Skip scheduled date', + }, + ]} + /> + ); +} diff --git a/packages/desktop-client/src/components/modals/SingleInput.tsx b/packages/desktop-client/src/components/modals/SingleInputModal.tsx similarity index 51% rename from packages/desktop-client/src/components/modals/SingleInput.tsx rename to packages/desktop-client/src/components/modals/SingleInputModal.tsx index 5906142a400d7a5f792e50fe603e2f62283b2ccb..c95d820fabe9ffceab892d0615babf0c46c6dc37 100644 --- a/packages/desktop-client/src/components/modals/SingleInput.tsx +++ b/packages/desktop-client/src/components/modals/SingleInputModal.tsx @@ -5,12 +5,12 @@ import { styles } from '../../style'; import { Button } from '../common/Button'; import { FormError } from '../common/FormError'; import { InitialFocus } from '../common/InitialFocus'; -import { Input } from '../common/Input'; import { Modal } from '../common/Modal'; import { View } from '../common/View'; +import { InputField } from '../mobile/MobileForms'; import { type CommonModalProps } from '../Modals'; -type SingleInputProps = { +type SingleInputModalProps = { modalProps: Partial<CommonModalProps>; title: string; buttonText: string; @@ -19,14 +19,14 @@ type SingleInputProps = { inputPlaceholder?: string; }; -export function SingleInput({ +export function SingleInputModal({ modalProps, title, buttonText, onSubmit, onValidate, inputPlaceholder, -}: SingleInputProps) { +}: SingleInputModalProps) { const [value, setValue] = useState(''); const [errorMessage, setErrorMessage] = useState(null); @@ -40,49 +40,57 @@ export function SingleInput({ onSubmit?.(value); modalProps.onClose(); }; + return ( - <Modal title={title} {...modalProps}> + <Modal + title={title} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > {() => ( <> - <View - style={{ - flexDirection: 'row', - justifyContent: 'center', - paddingBottom: 15, - }} - > - <View style={{ flexDirection: 'column', flex: 1 }}> - <InitialFocus> - <Input - placeholder={inputPlaceholder} - style={{ ...styles.mediumText }} - value={value} - onChangeValue={setValue} - onEnter={e => _onSubmit(e.currentTarget.value)} - /> - </InitialFocus> - {errorMessage && ( - <FormError style={{ paddingTop: 5 }}> - * {errorMessage} - </FormError> - )} - </View> + <View> + <InitialFocus> + <InputField + placeholder={inputPlaceholder} + defaultValue={value} + onUpdate={setValue} + onEnter={e => _onSubmit(e.currentTarget.value)} + /> + </InitialFocus> + {errorMessage && ( + <FormError + style={{ + paddingTop: 5, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + }} + > + * {errorMessage} + </FormError> + )} </View> - <View style={{ - flexDirection: 'row', - alignContent: 'center', justifyContent: 'center', + alignItems: 'center', + marginTop: 10, }} > <Button type="primary" style={{ - ...styles.mediumText, - flexBasis: '50%', + height: styles.mobileMinHeight, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, }} - onPointerUp={() => _onSubmit(value)} + onClick={() => _onSubmit(value)} > {buttonText} </Button> diff --git a/packages/desktop-client/src/components/modals/TransferModal.tsx b/packages/desktop-client/src/components/modals/TransferModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7def9a00471042d4139d252e493100ac94926330 --- /dev/null +++ b/packages/desktop-client/src/components/modals/TransferModal.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { pushModal } from 'loot-core/client/actions'; +import { evalArithmetic } from 'loot-core/shared/arithmetic'; +import { amountToInteger, integerToCurrency } from 'loot-core/shared/util'; + +import { useCategories } from '../../hooks/useCategories'; +import { styles } from '../../style'; +import { addToBeBudgetedGroup } from '../budget/util'; +import { Button } from '../common/Button'; +import { InitialFocus } from '../common/InitialFocus'; +import { Modal } from '../common/Modal'; +import { View } from '../common/View'; +import { FieldLabel, InputField, TapField } from '../mobile/MobileForms'; +import { type CommonModalProps } from '../Modals'; + +type TransferModalProps = { + modalProps: CommonModalProps; + title: string; + amount: number; + showToBeBudgeted: boolean; + onSubmit: (amount: number, toCategoryId: string) => void; +}; + +export function TransferModal({ + modalProps, + title, + amount: initialAmount, + showToBeBudgeted, + onSubmit, +}: TransferModalProps) { + const { grouped: originalCategoryGroups, list: categories } = useCategories(); + let categoryGroups = originalCategoryGroups.filter(g => !g.is_income); + if (showToBeBudgeted) { + categoryGroups = addToBeBudgetedGroup(categoryGroups); + } + + const _initialAmount = integerToCurrency(Math.max(initialAmount, 0)); + const [amount, setAmount] = useState<string | null>(null); + const [toCategoryId, setToCategoryId] = useState<string | null>(null); + const dispatch = useDispatch(); + + const openCategoryModal = () => { + dispatch( + pushModal('category-autocomplete', { + categoryGroups, + showHiddenCategories: true, + onSelect: categoryId => { + setToCategoryId(categoryId); + }, + }), + ); + }; + + const _onSubmit = (newAmount: string | null, categoryId: string | null) => { + const parsedAmount = evalArithmetic(newAmount || ''); + if (parsedAmount && categoryId) { + onSubmit?.(amountToInteger(parsedAmount), categoryId); + } + + modalProps.onClose(); + }; + + return ( + <Modal + title={title} + showHeader + focusAfterClose={false} + {...modalProps} + padding={0} + style={{ + flex: 1, + padding: '0 10px', + paddingBottom: 10, + borderRadius: '6px', + }} + > + {() => ( + <> + <View> + <FieldLabel title="Transfer this amount:" /> + <InitialFocus> + <InputField + tabIndex={1} + defaultValue={_initialAmount} + onUpdate={setAmount} + onEnter={() => { + if (!toCategoryId) { + openCategoryModal(); + } + }} + /> + </InitialFocus> + </View> + + <FieldLabel title="To:" /> + <TapField + tabIndex={2} + value={categories.find(c => c.id === toCategoryId)?.name} + onClick={openCategoryModal} + onFocus={openCategoryModal} + /> + + <View + style={{ + justifyContent: 'center', + alignItems: 'center', + marginTop: 10, + }} + > + <Button + type="primary" + tabIndex={3} + style={{ + height: styles.mobileMinHeight, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + }} + onClick={() => _onSubmit(amount, toCategoryId)} + > + Transfer + </Button> + </View> + </> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/hooks/useInitialMount.ts b/packages/desktop-client/src/hooks/useInitialMount.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d6b2d990e925f44d787dfa6a6d5132992cb1909 --- /dev/null +++ b/packages/desktop-client/src/hooks/useInitialMount.ts @@ -0,0 +1,12 @@ +import { useRef } from 'react'; + +export function useInitialMount(): boolean { + const initial = useRef(true); + + if (initial.current) { + initial.current = false; + return true; + } + + return false; +} diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index c4d54d17d93a389b4f0e2055cd3c3d95c76f6181..f780f445e7f4c66c7072bb20b4beabdc15c4e868 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -103,6 +103,24 @@ type FinanceModals = { onClose: () => void; }; + 'category-autocomplete': { + categoryGroups: CategoryGroupEntity[]; + onSelect: (categoryId: string, categoryName: string) => void; + showHiddenCategories?: boolean; + onClose?: () => void; + }; + + 'account-autocomplete': { + onSelect: (accountId: string, accountName: string) => void; + includeClosedAccounts?: boolean; + onClose?: () => void; + }; + + 'payee-autocomplete': { + onSelect: (payeeId: string) => void; + onClose?: () => void; + }; + 'budget-summary': { month: string; }; @@ -115,6 +133,14 @@ type FinanceModals = { 'schedule-posts-offline-notification': null; 'switch-budget-type': { onSwitch: () => void }; + 'account-menu': { + accountId: string; + onSave: (account: AccountEntity) => void; + onCloseAccount: (accountId: string) => void; + onReopenAccount: (accountId: string) => void; + onEditNotes: (id: string) => void; + onClose?: () => void; + }; 'category-menu': { categoryId: string; onSave: (category: CategoryEntity) => void; @@ -152,6 +178,43 @@ type FinanceModals = { onValidate?: (value: string) => string; onSubmit: (value: string) => Promise<void>; }; + 'rollover-balance-menu': { + categoryId: string; + month: string; + onCarryover: (carryover: boolean) => void; + onTransfer: () => void; + onCover: () => void; + }; + 'rollover-to-budget-menu': { + month: string; + onTransfer: () => void; + onHoldBuffer: () => void; + onResetHoldBuffer: () => void; + }; + 'report-balance-menu': { + categoryId: string; + month: string; + onCarryover: (carryover: boolean) => void; + }; + transfer: { + title: string; + amount: number; + onSubmit: (amount: number, toCategoryId: string) => void; + showToBeBudgeted?: boolean; + }; + cover: { + categoryId: string; + onSubmit: (fromCategoryId: string) => void; + }; + 'hold-buffer': { + month: string; + onSubmit: (amount: number) => void; + }; + 'scheduled-transaction-menu': { + transactionId: string; + onPost: (transactionId: string) => void; + onSkip: (transactionId: string) => void; + }; }; export type PushModalAction = { diff --git a/upcoming-release-notes/2472.md b/upcoming-release-notes/2472.md new file mode 100644 index 0000000000000000000000000000000000000000..dcbd90e59d6c66a97fc53f9ea4034bf2eb8b47f4 --- /dev/null +++ b/upcoming-release-notes/2472.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +Add more modals in mobile for account, scheduled transactions, budget summary, and balance actions.