From 477e8dc21f07d48f176494cf9211896b8b552761 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Sat, 30 Mar 2024 19:41:18 -0700 Subject: [PATCH] Mobile category transactions --- .../desktop-client/src/components/App.tsx | 2 +- .../src/components/FinancesApp.tsx | 15 +- .../src/components/accounts/Account.jsx | 42 +--- .../src/components/common/ButtonLink.tsx | 4 +- .../{ => mobile}/MobileBackButton.tsx | 11 +- .../{ => mobile}/MobileWebMessage.tsx | 15 +- .../components/mobile/accounts/Account.jsx | 231 ++--------------- .../mobile/accounts/AccountTransactions.jsx | 233 ++++++++++++++++++ .../components/mobile/accounts/Accounts.jsx | 15 +- .../components/mobile/budget/BudgetTable.jsx | 7 + .../src/components/mobile/budget/Category.tsx | 38 +++ .../mobile/budget/CategoryTransactions.jsx | 150 +++++++++++ .../transactions/AddTransactionButton.tsx | 36 +++ .../mobile/transactions/Transaction.jsx | 34 ++- .../mobile/transactions/TransactionEdit.jsx | 28 ++- .../mobile/transactions/TransactionList.jsx | 14 +- .../TransactionListWithBalances.jsx} | 131 ++-------- .../src/hooks/usePreviewTransactions.ts | 32 +++ packages/loot-core/src/client/queries.ts | 41 +++ 19 files changed, 650 insertions(+), 429 deletions(-) rename packages/desktop-client/src/components/{ => mobile}/MobileBackButton.tsx (77%) rename packages/desktop-client/src/components/{ => mobile}/MobileWebMessage.tsx (88%) create mode 100644 packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx create mode 100644 packages/desktop-client/src/components/mobile/budget/Category.tsx create mode 100644 packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx create mode 100644 packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx rename packages/desktop-client/src/components/mobile/{accounts/AccountDetails.jsx => transactions/TransactionListWithBalances.jsx} (50%) create mode 100644 packages/desktop-client/src/hooks/usePreviewTransactions.ts diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 0b4ea838eef..f19afc66f22 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -27,7 +27,7 @@ import { DevelopmentTopBar } from './DevelopmentTopBar'; import { FatalError } from './FatalError'; import { FinancesApp } from './FinancesApp'; import { ManagementApp } from './manager/ManagementApp'; -import { MobileWebMessage } from './MobileWebMessage'; +import { MobileWebMessage } from './mobile/MobileWebMessage'; import { UpdateNotification } from './UpdateNotification'; type AppInnerProps = { diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 2a1bc50a067..4718a93d465 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -30,6 +30,7 @@ import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext'; import { View } from './common/View'; import { GlobalKeys } from './GlobalKeys'; import { ManageRulesPage } from './ManageRulesPage'; +import { Category } from './mobile/budget/Category'; import { MobileNavTabs } from './mobile/MobileNavTabs'; import { TransactionEdit } from './mobile/transactions/TransactionEdit'; import { Modals } from './Modals'; @@ -210,7 +211,7 @@ function FinancesAppWithoutContext() { /> @@ -219,18 +220,10 @@ function FinancesAppWithoutContext() { /> - - - } - /> - - + } /> diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index 3efff4ec981..39316469bb0 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -8,10 +8,7 @@ import { bindActionCreators } from 'redux'; import { validForTransfer } from 'loot-core/client/transfer'; import * as actions from 'loot-core/src/client/actions'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; -import { - SchedulesProvider, - useCachedSchedules, -} from 'loot-core/src/client/data-hooks/schedules'; +import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules'; import * as queries from 'loot-core/src/client/queries'; import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; @@ -33,6 +30,7 @@ import { useDateFormat } from '../../hooks/useDateFormat'; import { useFailedAccounts } from '../../hooks/useFailedAccounts'; import { useLocalPref } from '../../hooks/useLocalPref'; import { usePayees } from '../../hooks/usePayees'; +import { usePreviewTransactions } from '../../hooks/usePreviewTransactions'; import { SelectedProviderWithItems } from '../../hooks/useSelected'; import { SplitsExpandedProvider, @@ -94,38 +92,14 @@ function AllTransactions({ filtered, children, }) { - const { id: accountId } = account; - const scheduleData = useCachedSchedules(); + const accountId = account.id; + const prependTransactions = usePreviewTransactions().map(trans => ({ + ...trans, + _inverse: accountId ? accountId !== trans.account : false, + })); transactions ??= []; - const schedules = useMemo( - () => - scheduleData - ? scheduleData.schedules.filter( - s => - !s.completed && - ['due', 'upcoming', 'missed'].includes( - scheduleData.statuses.get(s.id), - ), - ) - : [], - [scheduleData], - ); - - const prependTransactions = useMemo(() => { - return schedules.map(schedule => ({ - id: `preview/${schedule.id}`, - payee: schedule._payee, - account: schedule._account, - amount: schedule._amount, - date: schedule.next_date, - notes: scheduleData.statuses.get(schedule.id), - schedule: schedule.id, - _inverse: accountId ? accountId !== schedule._account : false, - })); - }, [schedules, accountId]); - let runningBalance = useMemo(() => { if (!showBalances) { return 0; @@ -172,7 +146,7 @@ function AllTransactions({ return balances; }, [filtered, prependBalances, balances]); - if (scheduleData == null) { + if (!prependTransactions) { return children(transactions, balances); } return children(allTransactions, allBalances); diff --git a/packages/desktop-client/src/components/common/ButtonLink.tsx b/packages/desktop-client/src/components/common/ButtonLink.tsx index fde95935d1e..927f205d1e1 100644 --- a/packages/desktop-client/src/components/common/ButtonLink.tsx +++ b/packages/desktop-client/src/components/common/ButtonLink.tsx @@ -9,11 +9,13 @@ import { Button } from './Button'; type ButtonLinkProps = ComponentProps & { to: string; activeStyle?: CSSProperties; + state?: Record; }; export function ButtonLink({ to, style, activeStyle, + state, ...props }: ButtonLinkProps) { const navigate = useNavigate(); @@ -28,7 +30,7 @@ export function ButtonLink({ {...props} onClick={e => { props.onClick?.(e); - navigate(to); + navigate(to, { state }); }} /> ); diff --git a/packages/desktop-client/src/components/MobileBackButton.tsx b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx similarity index 77% rename from packages/desktop-client/src/components/MobileBackButton.tsx rename to packages/desktop-client/src/components/mobile/MobileBackButton.tsx index d13b504dc72..6128ba3daee 100644 --- a/packages/desktop-client/src/components/MobileBackButton.tsx +++ b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx @@ -1,11 +1,10 @@ import React from 'react'; -import { useNavigate } from '../hooks/useNavigate'; -import { SvgCheveronLeft } from '../icons/v1'; -import { type CSSProperties, styles, theme } from '../style'; - -import { Button } from './common/Button'; -import { Text } from './common/Text'; +import { useNavigate } from '../../hooks/useNavigate'; +import { SvgCheveronLeft } from '../../icons/v1'; +import { type CSSProperties, styles, theme } from '../../style'; +import { Button } from '../common/Button'; +import { Text } from '../common/Text'; type MobileBackButtonProps = { style?: CSSProperties; diff --git a/packages/desktop-client/src/components/MobileWebMessage.tsx b/packages/desktop-client/src/components/mobile/MobileWebMessage.tsx similarity index 88% rename from packages/desktop-client/src/components/MobileWebMessage.tsx rename to packages/desktop-client/src/components/mobile/MobileWebMessage.tsx index c54da6eedf1..4c3af12212f 100644 --- a/packages/desktop-client/src/components/MobileWebMessage.tsx +++ b/packages/desktop-client/src/components/mobile/MobileWebMessage.tsx @@ -1,13 +1,12 @@ import React, { useState } from 'react'; -import { useLocalPref } from '../hooks/useLocalPref'; -import { useResponsive } from '../ResponsiveProvider'; -import { theme, styles } from '../style'; - -import { Button } from './common/Button'; -import { Text } from './common/Text'; -import { View } from './common/View'; -import { Checkbox } from './forms'; +import { useLocalPref } from '../../hooks/useLocalPref'; +import { useResponsive } from '../../ResponsiveProvider'; +import { theme, styles } from '../../style'; +import { Button } from '../common/Button'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; +import { Checkbox } from '../forms'; const buttonStyle = { border: 0, fontSize: 15, padding: '10px 13px' }; diff --git a/packages/desktop-client/src/components/mobile/accounts/Account.jsx b/packages/desktop-client/src/components/mobile/accounts/Account.jsx index 25c0bd4b17f..20ad5bc9bb9 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Account.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Account.jsx @@ -1,189 +1,36 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React from 'react'; +import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; -import memoizeOne from 'memoize-one'; -import { useDebounceCallback } from 'usehooks-ts'; - -import * as actions from 'loot-core/src/client/actions'; -import { - SchedulesProvider, - useCachedSchedules, -} 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 { - isPreviewId, - ungroupTransactions, -} from 'loot-core/src/shared/transactions'; - -import { useAccounts } from '../../../hooks/useAccounts'; -import { useCategories } from '../../../hooks/useCategories'; -import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useAccount } from '../../../hooks/useAccount'; import { useFailedAccounts } from '../../../hooks/useFailedAccounts'; import { useLocalPref } from '../../../hooks/useLocalPref'; -import { useLocalPrefs } from '../../../hooks/useLocalPrefs'; import { useNavigate } from '../../../hooks/useNavigate'; -import { usePayees } from '../../../hooks/usePayees'; import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; import { theme, styles } from '../../../style'; import { Button } from '../../common/Button'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; -import { AccountDetails } from './AccountDetails'; - -const getSchedulesTransform = memoizeOne((id, hasSearch) => { - let filter = queries.getAccountFilter(id, '_account'); - - // Never show schedules on these pages - if (hasSearch) { - filter = { id: null }; - } - - return q => { - q = q.filter({ $and: [filter, { '_account.closed': false }] }); - return q.orderBy({ next_date: 'desc' }); - }; -}); - -function PreviewTransactions({ children }) { - const scheduleData = useCachedSchedules(); - - if (scheduleData == null) { - return children(null); - } - - const schedules = scheduleData.schedules.filter( - s => - !s.completed && - ['due', 'upcoming', 'missed'].includes(scheduleData.statuses.get(s.id)), - ); - - return children( - schedules.map(schedule => ({ - id: 'preview/' + schedule.id, - payee: schedule._payee, - account: schedule._account, - amount: schedule._amount, - date: schedule.next_date, - notes: scheduleData.statuses.get(schedule.id), - schedule: schedule.id, - })), - ); -} - -export function Account(props) { - const accounts = useAccounts(); - const payees = usePayees(); +import { AccountTransactions } from './AccountTransactions'; +export function Account() { const failedAccounts = useFailedAccounts(); const syncingAccountIds = useSelector(state => state.account.accountsSyncing); const navigate = useNavigate(); - const [transactions, setTransactions] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [currentQuery, setCurrentQuery] = useState(); - const newTransactions = useSelector(state => state.queries.newTransactions); - const prefs = useLocalPrefs(); - const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const [_numberFormat] = useLocalPref('numberFormat'); const numberFormat = _numberFormat || 'comma-dot'; const [hideFraction = false] = useLocalPref('hideFraction'); - const state = { - payees, - newTransactions, - prefs, - dateFormat, - }; - - const dispatch = useDispatch(); - const { id: accountId } = useParams(); - const makeRootQuery = useCallback( - () => queries.makeTransactionsQuery(accountId), - [accountId], - ); - - const paged = useRef(null); - - const updateQuery = useCallback(query => { - paged.current?.unsubscribe(); - paged.current = pagedQuery( - query.options({ splits: 'grouped' }).select('*'), - data => setTransactions(data), - { pageCount: 10, mapper: ungroupTransactions }, - ); - }, []); - - const fetchTransactions = useCallback(async () => { - const query = makeRootQuery(); - setCurrentQuery(query); - updateQuery(query); - }, [makeRootQuery, updateQuery]); - - useEffect(() => { - let unlisten; - - async function setUpAccount() { - unlisten = listen('sync-event', ({ type, tables }) => { - if (type === 'applied') { - if ( - tables.includes('transactions') || - tables.includes('category_mapping') || - tables.includes('payee_mapping') - ) { - paged.current?.run(); - } - - if (tables.includes('payees') || tables.includes('payee_mapping')) { - dispatch(actions.getPayees()); - } - } - }); - - await fetchTransactions(); - - dispatch(actions.markAccountRead(accountId)); - } - - setUpAccount(); - - return () => unlisten(); - }, [accountId, dispatch, fetchTransactions]); - - // Load categories if necessary. - const categories = useCategories(); - - const updateSearchQuery = useDebounceCallback( - useCallback( - searchText => { - if (searchText === '' && currentQuery) { - updateQuery(currentQuery); - } else if (searchText && currentQuery) { - updateQuery( - queries.makeTransactionSearchQuery( - currentQuery, - searchText, - dateFormat, - ), - ); - } - - setIsSearching(searchText !== ''); - }, - [currentQuery, dateFormat, updateQuery], - ), - 150, - ); - useSetThemeColor(theme.mobileViewTheme); - if (!accounts || !accounts.length) { + const account = useAccount(accountId); + + if (!account) { return null; } @@ -208,60 +55,14 @@ export function Account(props) { ); } - const account = accounts.find(acct => acct.id === accountId); - - const isNewTransaction = id => { - return state.newTransactions.includes(id); - }; - - const onSearch = text => { - updateSearchQuery(text); - }; - - const onSelectTransaction = transaction => { - // 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}`); - } - }; - - const balance = queries.accountBalance(account); - const balanceCleared = queries.accountBalanceCleared(account); - const balanceUncleared = queries.accountBalanceUncleared(account); - return ( - - - {prependTransactions => - prependTransactions == null ? null : ( - { - paged.current?.fetchNext(); - }} - onSearch={onSearch} - onSelectTransaction={onSelectTransaction} - /> - ) - } - - + ); } diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx new file mode 100644 index 00000000000..52b6228d6c7 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx @@ -0,0 +1,233 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch } from 'react-redux'; + +import memoizeOne from 'memoize-one'; +import { useDebounceCallback } from 'usehooks-ts'; + +import { + getPayees, + markAccountRead, + syncAndDownload, +} from 'loot-core/client/actions'; +import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules'; +import * as queries from 'loot-core/client/queries'; +import { pagedQuery } from 'loot-core/client/query-helpers'; +import { listen } from 'loot-core/platform/client/fetch'; +import { + isPreviewId, + ungroupTransactions, +} from 'loot-core/shared/transactions'; + +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { usePreviewTransactions } from '../../../hooks/usePreviewTransactions'; +import { theme } from '../../../style'; +import { View } from '../../common/View'; +import { Page } from '../../Page'; +import { MobileBackButton } from '../MobileBackButton'; +import { AddTransactionButton } from '../transactions/AddTransactionButton'; +import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances'; + +export function AccountTransactions({ account, pending, failed }) { + const [isSearching, setIsSearching] = useState(false); + + const onSearch = searchText => { + setIsSearching(searchText !== ''); + }; + + return ( + +
+ {account.name} + + ) + } + headerLeftContent={} + headerRightContent={ + + } + padding={0} + style={{ + flex: 1, + backgroundColor: theme.mobilePageBackground, + }} + > + + + + + ); +} + +const getSchedulesTransform = memoizeOne((id, hasSearch) => { + let filter = queries.getAccountFilter(id, '_account'); + + // Never show schedules on these pages + if (hasSearch) { + filter = { id: null }; + } + + return q => { + q = q.filter({ $and: [filter, { '_account.closed': false }] }); + return q.orderBy({ next_date: 'desc' }); + }; +}); + +function FilteredTransactionsWithPreviews({ account, onSearch }) { + const [currentQuery, setCurrentQuery] = useState(); + const [transactions, setTransactions] = useState([]); + const prependTransactions = usePreviewTransactions(); + const allTransactions = useMemo( + () => prependTransactions.concat(transactions), + [prependTransactions, transactions], + ); + + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const [_numberFormat] = useLocalPref('numberFormat'); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const onRefresh = async () => { + await dispatch(syncAndDownload(account.id)); + }; + + const makeRootQuery = useCallback( + () => queries.makeTransactionsQuery(account.id).options({ splits: 'none' }), + [account.id], + ); + + const paged = useRef(null); + + const updateQuery = useCallback(query => { + paged.current?.unsubscribe(); + paged.current = pagedQuery( + query.options({ splits: 'none' }).select('*'), + data => setTransactions(data), + { pageCount: 10, mapper: ungroupTransactions }, + ); + }, []); + + const fetchTransactions = useCallback(async () => { + const query = makeRootQuery(); + setCurrentQuery(query); + updateQuery(query); + }, [makeRootQuery, updateQuery]); + + useEffect(() => { + let unlisten; + + async function setUpAccount() { + unlisten = listen('sync-event', ({ type, tables }) => { + if (type === 'applied') { + if ( + tables.includes('transactions') || + tables.includes('category_mapping') || + tables.includes('payee_mapping') + ) { + paged.current?.run(); + } + + if (tables.includes('payees') || tables.includes('payee_mapping')) { + dispatch(getPayees()); + } + } + }); + + await fetchTransactions(); + + dispatch(markAccountRead(account.id)); + } + + setUpAccount(); + + return () => unlisten(); + }, [account.id, dispatch, fetchTransactions]); + + const updateSearchQuery = useDebounceCallback( + useCallback( + searchText => { + if (searchText === '' && currentQuery) { + updateQuery(currentQuery); + } else if (searchText && currentQuery) { + updateQuery( + queries.makeTransactionSearchQuery( + currentQuery, + searchText, + dateFormat, + ), + ); + } + }, + [currentQuery, dateFormat, updateQuery], + ), + 150, + ); + + const _onSearch = text => { + updateSearchQuery(text); + onSearch?.(text); + }; + + const onSelectTransaction = transaction => { + // 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}`); + } + }; + + const onLoadMore = () => { + paged.current?.fetchNext(); + }; + + const balance = queries.accountBalance(account); + const balanceCleared = queries.accountBalanceCleared(account); + const balanceUncleared = queries.accountBalanceUncleared(account); + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx index 9f75e6d9ba1..3ecc0867080 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx @@ -1,11 +1,10 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { replaceModal, syncAndDownload } from 'loot-core/src/client/actions'; import * as queries from 'loot-core/src/client/queries'; import { useAccounts } from '../../../hooks/useAccounts'; -import { useCategories } from '../../../hooks/useCategories'; import { useFailedAccounts } from '../../../hooks/useFailedAccounts'; import { useLocalPref } from '../../../hooks/useLocalPref'; import { useNavigate } from '../../../hooks/useNavigate'; @@ -247,25 +246,17 @@ function AccountList({ export function Accounts() { const dispatch = useDispatch(); const accounts = useAccounts(); - const newTransactions = useSelector(state => state.queries.newTransactions); const updatedAccounts = useSelector(state => state.queries.updatedAccounts); const [_numberFormat] = useLocalPref('numberFormat'); const numberFormat = _numberFormat || 'comma-dot'; const [hideFraction = false] = useLocalPref('hideFraction'); - const { list: categories } = useCategories(); - - const transactions = useState({}); const navigate = useNavigate(); const onSelectAccount = id => { navigate(`/accounts/${id}`); }; - const onSelectTransaction = transaction => { - navigate(`/transaction/${transaction}`); - }; - const onAddAccount = () => { dispatch(replaceModal('add-account')); }; @@ -283,16 +274,12 @@ export function Accounts() { // format changes key={numberFormat + hideFraction} accounts={accounts.filter(account => !account.closed)} - categories={categories} - transactions={transactions || []} updatedAccounts={updatedAccounts} - newTransactions={newTransactions} getBalanceQuery={queries.accountBalance} getOnBudgetBalance={queries.budgetedAccountBalance} getOffBudgetBalance={queries.offbudgetAccountBalance} onAddAccount={onAddAccount} onSelectAccount={onSelectAccount} - onSelectTransaction={onSelectTransaction} onSync={onSync} /> diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 92bdf7fd602..1465928c046 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -9,6 +9,7 @@ import * as monthUtils from 'loot-core/src/shared/months'; import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useNavigate } from '../../../hooks/useNavigate'; import { SingleActiveEditFormProvider, useSingleActiveEditForm, @@ -285,6 +286,10 @@ const ExpenseCategory = memo(function ExpenseCategory({ arg, ); }; + const navigate = useNavigate(); + const onShowActivity = () => { + navigate(`/categories/${category.id}?month=${month}`); + }; const content = ( c.id === categoryId); + + if (category == null) { + return null; + } + + return ( + + ); +} diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx new file mode 100644 index 00000000000..860affb7aeb --- /dev/null +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { useDebounceCallback } from 'usehooks-ts'; + +import { getPayees } from 'loot-core/client/actions'; +import * as queries from 'loot-core/client/queries'; +import { pagedQuery } from 'loot-core/client/query-helpers'; +import { listen } from 'loot-core/platform/client/fetch'; +import { q } from 'loot-core/shared/query'; +import { + isPreviewId, + ungroupTransactions, +} from 'loot-core/shared/transactions'; + +import { useDateFormat } from '../../../hooks/useDateFormat'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useNavigate } from '../../../hooks/useNavigate'; +import { theme } from '../../../style'; +import { Page } from '../../Page'; +import { MobileBackButton } from '../MobileBackButton'; +import { AddTransactionButton } from '../transactions/AddTransactionButton'; +import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances'; + +export function CategoryTransactions({ category, month }) { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const [currentQuery, setCurrentQuery] = useState(); + const [transactions, setTransactions] = useState([]); + + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const [_numberFormat] = useLocalPref('numberFormat'); + + const makeRootQuery = useCallback( + () => + q('transactions') + .options({ splits: 'inline' }) + .filter(getCategoryMonthFilter(category, month)), + [category, month], + ); + + const paged = useRef(null); + + const updateQuery = useCallback(query => { + paged.current?.unsubscribe(); + paged.current = pagedQuery( + query.options({ splits: 'inline' }).select('*'), + data => setTransactions(data), + { pageCount: 10, mapper: ungroupTransactions }, + ); + }, []); + + const fetchTransactions = useCallback(async () => { + const query = makeRootQuery(); + setCurrentQuery(query); + updateQuery(query); + }, [makeRootQuery, updateQuery]); + + useEffect(() => { + function setup() { + return listen('sync-event', ({ type, tables }) => { + if (type === 'applied') { + if ( + tables.includes('transactions') || + tables.includes('category_mapping') || + tables.includes('payee_mapping') + ) { + paged.current?.run(); + } + + if (tables.includes('payees') || tables.includes('payee_mapping')) { + dispatch(getPayees()); + } + } + }); + } + + fetchTransactions(); + return setup(); + }, [dispatch, fetchTransactions]); + + const updateSearchQuery = useDebounceCallback( + useCallback( + searchText => { + if (searchText === '' && currentQuery) { + updateQuery(currentQuery); + } else if (searchText && currentQuery) { + updateQuery( + queries + .makeTransactionSearchQuery(currentQuery, searchText, dateFormat) + .options({ splits: 'inline' }), + ); + } + }, + [currentQuery, dateFormat, updateQuery], + ), + 150, + ); + + const onSearch = text => { + updateSearchQuery(text); + }; + + const onLoadMore = () => { + paged.current?.fetchNext(); + }; + + const onSelectTransaction = transaction => { + // 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}`); + } + }; + + const balance = queries.categoryBalance(category, month); + const balanceCleared = queries.categoryBalanceCleared(category, month); + const balanceUncleared = queries.categoryBalanceUncleared(category, month); + + return ( + } + headerRightContent={ + + } + padding={0} + style={{ + flex: 1, + backgroundColor: theme.mobilePageBackground, + }} + > + + + ); +} + +function getCategoryMonthFilter(category, month) { + return { + category: category.id, + date: { $transform: '$month', $eq: month }, + }; +} diff --git a/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx b/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx new file mode 100644 index 00000000000..fce1210abe0 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx @@ -0,0 +1,36 @@ +import React, { type ComponentPropsWithoutRef } from 'react'; + +import { SvgAdd } from '../../../icons/v1'; +import { theme } from '../../../style'; +import { ButtonLink } from '../../common/ButtonLink'; + +type AddTransactionButtonProps = { + to: ComponentPropsWithoutRef['to']; + state?: ComponentPropsWithoutRef['state']; +}; + +export function AddTransactionButton({ + to = '/transactions/new', + state, +}: AddTransactionButtonProps) { + return ( + + + + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx index 055239cc7d6..855c7f44a63 100644 --- a/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/Transaction.jsx @@ -1,9 +1,13 @@ -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { isPreviewId } from 'loot-core/src/shared/transactions'; -import { integerToCurrency, groupById } from 'loot-core/src/shared/util'; +import { integerToCurrency } from 'loot-core/src/shared/util'; +import { useAccount } from '../../../hooks/useAccount'; +import { useCategories } from '../../../hooks/useCategories'; +import { usePayee } from '../../../hooks/usePayee'; +import { SvgSplit } from '../../../icons/v0'; import { SvgArrowsSynchronize, SvgCheckCircle1, @@ -41,28 +45,29 @@ ListItem.displayName = 'ListItem'; export const Transaction = memo(function Transaction({ transaction, - account, - accounts, - categories, - payees, added, onSelect, style, }) { - const accountsById = useMemo(() => groupById(accounts), [accounts]); - const payeesById = useMemo(() => groupById(payees), [payees]); + const { list: categories } = useCategories(); const { id, payee: payeeId, amount: originalAmount, category: categoryId, + account: accountId, cleared, is_parent: isParent, + is_child: isChild, notes, schedule, } = transaction; + const payee = usePayee(payeeId); + const account = useAccount(accountId); + const transferAcct = useAccount(payee?.transfer_acct); + const isPreview = isPreviewId(id); let amount = originalAmount; if (isPreview) { @@ -71,10 +76,6 @@ export const Transaction = memo(function Transaction({ const categoryName = lookupName(categories, categoryId); - const payee = payeesById && payeeId && payeesById[payeeId]; - const transferAcct = - payee && payee.transfer_acct && accountsById[payee.transfer_acct]; - const prettyDescription = getDescriptionPretty( transaction, payee, @@ -173,6 +174,15 @@ export const Transaction = memo(function Transaction({ }} /> )} + {(isParent || isChild) && ( + + )} ); diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx similarity index 50% rename from packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx rename to packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx index b73909fd337..344c8a36f82 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -1,23 +1,18 @@ -import React, { useState, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; -import { syncAndDownload } from 'loot-core/client/actions'; - -import { SvgAdd } from '../../../icons/v1'; import { SvgSearchAlternate } from '../../../icons/v2'; import { styles, theme } from '../../../style'; -import { ButtonLink } from '../../common/ButtonLink'; import { InputWithContent } from '../../common/InputWithContent'; import { Label } from '../../common/Label'; import { View } from '../../common/View'; -import { MobileBackButton } from '../../MobileBackButton'; -import { Page } from '../../Page'; import { CellValue } from '../../spreadsheet/CellValue'; import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { PullToRefresh } from '../PullToRefresh'; -import { TransactionList } from '../transactions/TransactionList'; -function TransactionSearchInput({ accountName, onSearch }) { +import { TransactionList } from './TransactionList'; + +function TransactionSearchInput({ placeholder, onSearch }) { const [text, setText] = useState(''); return ( @@ -48,7 +43,7 @@ function TransactionSearchInput({ accountName, onSearch }) { setText(text); onSearch(text); }} - placeholder={`Search ${accountName}`} + placeholder={placeholder} style={{ backgroundColor: theme.tableBackground, border: `1px solid ${theme.formInputBorder}`, @@ -60,91 +55,28 @@ function TransactionSearchInput({ accountName, onSearch }) { ); } -export function AccountDetails({ - account, - pending, - failed, - prependTransactions, +export function TransactionListWithBalances({ transactions, - accounts, - categories, - payees, balance, balanceCleared, balanceUncleared, - isNewTransaction, - onLoadMore, onSearch, + onLoadMore, onSelectTransaction, + onRefresh, }) { - const allTransactions = useMemo(() => { - return prependTransactions.concat(transactions); - }, [prependTransactions, transactions]); + const newTransactions = useSelector(state => state.queries.newTransactions); - const dispatch = useDispatch(); - const onRefresh = async () => { - await dispatch(syncAndDownload(account.id)); + const isNewTransaction = id => { + return newTransactions.includes(id); }; + const unclearedAmount = useSheetValue(balanceUncleared); + return ( - -
- {account.name} - - ) - } - headerLeftContent={} - headerRightContent={ - - - - } - padding={0} - style={{ - flex: 1, - backgroundColor: theme.mobilePageBackground, - }} - > + <> - + - + - + - + ); } diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts new file mode 100644 index 00000000000..1200089ea85 --- /dev/null +++ b/packages/desktop-client/src/hooks/usePreviewTransactions.ts @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; + +import { useCachedSchedules } from 'loot-core/client/data-hooks/schedules'; + +export function usePreviewTransactions() { + const scheduleData = useCachedSchedules(); + + return useMemo(() => { + if (!scheduleData) { + return []; + } + + const schedules = + scheduleData.schedules?.filter( + s => + !s.completed && + ['due', 'upcoming', 'missed'].includes( + scheduleData.statuses?.get(s.id), + ), + ) || []; + + return schedules.map(schedule => ({ + id: 'preview/' + schedule.id, + payee: schedule._payee, + account: schedule._account, + amount: schedule._amount, + date: schedule.next_date, + notes: scheduleData.statuses.get(schedule.id), + schedule: schedule.id, + })); + }, [scheduleData]); +} diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts index f8ac400a04e..d4320ee52be 100644 --- a/packages/loot-core/src/client/queries.ts +++ b/packages/loot-core/src/client/queries.ts @@ -151,6 +151,47 @@ export function offbudgetAccountBalance() { }; } +export function categoryBalance(category, month) { + return { + name: `balance-${category.id}`, + query: q('transactions') + .filter({ + category: category.id, + date: { $transform: '$month', $eq: month }, + }) + .options({ splits: 'inline' }) + .calculate({ $sum: '$amount' }), + }; +} + +export function categoryBalanceCleared(category, month) { + return { + name: `balanceCleared-${category.id}`, + query: q('transactions') + .filter({ + category: category.id, + date: { $transform: '$month', $eq: month }, + cleared: true, + }) + .options({ splits: 'inline' }) + .calculate({ $sum: '$amount' }), + }; +} + +export function categoryBalanceUncleared(category, month) { + return { + name: `balanceUncleared-${category.id}`, + query: q('transactions') + .filter({ + category: category.id, + date: { $transform: '$month', $eq: month }, + cleared: false, + }) + .options({ splits: 'inline' }) + .calculate({ $sum: '$amount' }), + }; +} + const uncategorizedQuery = q('transactions').filter({ 'account.offbudget': false, category: null,