diff --git a/packages/desktop-client/e2e/page-models/mobile-account-page.js b/packages/desktop-client/e2e/page-models/mobile-account-page.js index 9e58489fb74..0d831015c3b 100644 --- a/packages/desktop-client/e2e/page-models/mobile-account-page.js +++ b/packages/desktop-client/e2e/page-models/mobile-account-page.js @@ -5,7 +5,7 @@ export class MobileAccountPage { this.page = page; this.heading = page.getByRole('heading'); - this.balance = page.getByTestId('account-balance'); + this.balance = page.getByTestId('transactions-balance'); this.noTransactionsFoundError = page.getByText('No transactions'); this.searchBox = page.getByPlaceholder(/^Search/); this.transactionList = page.getByLabel('transaction list'); diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 5fc229e140b..5a63cdd58b7 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 010949fc6cb..25bef745319 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/Link.tsx b/packages/desktop-client/src/components/common/Link.tsx index d624bdaf682..1fd6a027742 100644 --- a/packages/desktop-client/src/components/common/Link.tsx +++ b/packages/desktop-client/src/components/common/Link.tsx @@ -102,7 +102,9 @@ const ButtonLink = ({ to, style, activeStyle, ...props }: ButtonLinkProps) => { {...props} onClick={e => { props.onClick?.(e); - navigate(path); + if (!e.defaultPrevented) { + navigate(path); + } }} /> ); diff --git a/packages/desktop-client/src/components/MobileBackButton.tsx b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx similarity index 76% rename from packages/desktop-client/src/components/MobileBackButton.tsx rename to packages/desktop-client/src/components/mobile/MobileBackButton.tsx index d13b504dc72..f563dcab834 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; @@ -16,6 +15,7 @@ export function MobileBackButton({ style }: MobileBackButtonProps) { return ( + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx index 028c1787e3e..402a950d8d3 100644 --- a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx @@ -15,7 +15,7 @@ export function ListBox(props) { const { loadMore } = props; const { hasScrolledToBottom } = useScroll(); - const scrolledToBottom = hasScrolledToBottom(); + const scrolledToBottom = hasScrolledToBottom(5); const prevScrolledToBottom = usePrevious(scrolledToBottom); if (!prevScrolledToBottom && scrolledToBottom) { 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/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx new file mode 100644 index 00000000000..1eddb932d2f --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { SvgSearchAlternate } from '../../../icons/v2'; +import { styles, theme } from '../../../style'; +import { InputWithContent } from '../../common/InputWithContent'; +import { Label } from '../../common/Label'; +import { View } from '../../common/View'; +import { CellValue } from '../../spreadsheet/CellValue'; +import { useSheetValue } from '../../spreadsheet/useSheetValue'; +import { PullToRefresh } from '../PullToRefresh'; + +import { TransactionList } from './TransactionList'; + +function TransactionSearchInput({ placeholder, onSearch }) { + const [text, setText] = useState(''); + + return ( + + + } + value={text} + onChangeValue={text => { + setText(text); + onSearch(text); + }} + placeholder={placeholder} + style={{ + backgroundColor: theme.tableBackground, + border: `1px solid ${theme.formInputBorder}`, + flex: 1, + height: styles.mobileMinHeight, + }} + /> + + ); +} + +export function TransactionListWithBalances({ + transactions, + balance, + balanceCleared, + balanceUncleared, + searchPlaceholder = 'Search...', + onSearch, + onLoadMore, + onSelectTransaction, + onRefresh, +}) { + const newTransactions = useSelector(state => state.queries.newTransactions); + + const isNewTransaction = id => { + return newTransactions.includes(id); + }; + + const unclearedAmount = useSheetValue(balanceUncleared); + + return ( + <> + + + + + + + + + + + + + + + + ); +} diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts new file mode 100644 index 00000000000..4f4f57b3ef0 --- /dev/null +++ b/packages/desktop-client/src/hooks/usePreviewTransactions.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; + +import { + type ScheduleStatuses, + useCachedSchedules, +} from 'loot-core/client/data-hooks/schedules'; +import { type ScheduleEntity } from 'loot-core/types/models'; + +export function usePreviewTransactions() { + const scheduleData = useCachedSchedules(); + + return useMemo(() => { + if (!scheduleData) { + return []; + } + + const schedules = + scheduleData.schedules.filter(s => + isForPreview(s, scheduleData.statuses), + ) || []; + + 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]); +} + +function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) { + const status = statuses.get(schedule.id); + return ( + !schedule.completed && + (status === 'due' || status === 'upcoming' || status === 'missed') + ); +} diff --git a/packages/loot-core/src/client/data-hooks/schedules.tsx b/packages/loot-core/src/client/data-hooks/schedules.tsx index 9902efac09c..2f6117dea84 100644 --- a/packages/loot-core/src/client/data-hooks/schedules.tsx +++ b/packages/loot-core/src/client/data-hooks/schedules.tsx @@ -25,14 +25,14 @@ function loadStatuses(schedules: ScheduleEntity[], onData) { } type UseSchedulesArgs = { transform?: (q: Query) => Query }; -type UseSchedulesReturnType = { +type UseSchedulesResult = { schedules: ScheduleEntity[]; statuses: ScheduleStatuses; } | null; export function useSchedules({ transform, -}: UseSchedulesArgs = {}): UseSchedulesReturnType { - const [data, setData] = useState(null); +}: UseSchedulesArgs = {}): UseSchedulesResult { + const [data, setData] = useState(null); useEffect(() => { const query = q('schedules').select('*'); @@ -66,7 +66,11 @@ export function useSchedules({ return data; } -const SchedulesContext = createContext(null); +type SchedulesContextValue = UseSchedulesResult; + +const SchedulesContext = createContext( + undefined, +); export function SchedulesProvider({ transform, children }) { const data = useSchedules({ transform }); 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, diff --git a/upcoming-release-notes/2531.md b/upcoming-release-notes/2531.md new file mode 100644 index 00000000000..2dbc168090e --- /dev/null +++ b/upcoming-release-notes/2531.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Drill down category transactions by clicking on spent amount in mobile budget page.