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 9e58489fb74eb2ca7e494015cd0c44006bbc745f..0d831015c3bdd94f8e74b6758416f959f9b92bda 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 5fc229e140bda313fdf690bb95b98f1e55d52266..5a63cdd58b740295ed26147bbdf4ab1ecf67103f 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 2a1bc50a06705a66a37b6c86fba169959da07a6e..4718a93d46536fb3ad1ebd2b5219990f0581ee08 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() { /> <Route - path="/accounts/:id/transactions/:transactionId" + path="/transactions/:transactionId" element={ <WideNotSupported> <TransactionEdit /> @@ -219,18 +220,10 @@ function FinancesAppWithoutContext() { /> <Route - path="/accounts/:id/transactions/new" + path="/categories/:id" element={ <WideNotSupported> - <TransactionEdit /> - </WideNotSupported> - } - /> - <Route - path="/transactions/new" - element={ - <WideNotSupported> - <TransactionEdit /> + <Category /> </WideNotSupported> } /> diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index 010949fc6cb58463e4456ba356c0cb3edbf6b6ce..25bef745319603d09b305b92ccfc6e63fb53ceb1 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 d624bdaf6821722eeb85cfc87c99a1659c656a1f..1fd6a027742d283f85eb03d74bfa8aad5eda583f 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 d13b504dc7222043566f226e6da169eb6f9c246e..f563dcab83457070cf84fb33fde1e48c51ff7175 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 ( <Button type="bare" + aria-label="Back" style={{ color: theme.mobileHeaderText, justifyContent: 'center', 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 ae4b17ebaacf3f78ea26d11fdf9bc8717208d15c..edf8d5ad0b96be6f32baa22b219e01b4237ef0e6 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 4a07c391e6ede2b600d98f746b7ca4f4e882dd8d..20ad5bc9bb9cc8350cb9e338bd904108a1ec9fca 100644 --- a/packages/desktop-client/src/components/mobile/accounts/Account.jsx +++ b/packages/desktop-client/src/components/mobile/accounts/Account.jsx @@ -1,193 +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, send } 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]); - - const refetchTransactions = () => { - paged.current?.run(); - }; - - 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') - ) { - refetchTransactions(); - } - - 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; } @@ -212,77 +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}`); - } 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')); - }, - }), - ); - } - }; - - const balance = queries.accountBalance(account); - const balanceCleared = queries.accountBalanceCleared(account); - const balanceUncleared = queries.accountBalanceUncleared(account); - return ( - <SchedulesProvider - transform={getSchedulesTransform(accountId, isSearching)} - > - <PreviewTransactions accountId={props.accountId}> - {prependTransactions => - prependTransactions == null ? null : ( - <AccountDetails - // This key forces the whole table rerender when the number - // format changes - {...state} - key={numberFormat + hideFraction} - account={account} - pending={syncingAccountIds.includes(account.id)} - failed={failedAccounts && failedAccounts.has(account.id)} - accounts={accounts} - categories={categories.list} - payees={state.payees} - transactions={transactions} - prependTransactions={prependTransactions || []} - balance={balance} - balanceCleared={balanceCleared} - balanceUncleared={balanceUncleared} - isNewTransaction={isNewTransaction} - onLoadMore={() => { - paged.current?.fetchNext(); - }} - onSearch={onSearch} - onSelectTransaction={onSelectTransaction} - /> - ) - } - </PreviewTransactions> - </SchedulesProvider> + <AccountTransactions + // This key forces the whole table rerender when the number + // format changes + key={numberFormat + hideFraction} + account={account} + pending={syncingAccountIds.includes(account.id)} + failed={failedAccounts && failedAccounts.has(account.id)} + /> ); } diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx b/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx deleted file mode 100644 index ad653191a38232e258a6749ad09590dc94e3933e..0000000000000000000000000000000000000000 --- a/packages/desktop-client/src/components/mobile/accounts/AccountDetails.jsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; - -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'; -import { styles, theme } from '../../../style'; -import { InputWithContent } from '../../common/InputWithContent'; -import { Label } from '../../common/Label'; -import { Link } from '../../common/Link'; -import { Text } from '../../common/Text'; -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 }) { - const [text, setText] = useState(''); - - return ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.mobilePageBackground, - padding: 10, - width: '100%', - }} - > - <InputWithContent - leftContent={ - <SvgSearchAlternate - style={{ - width: 13, - height: 13, - flexShrink: 0, - color: text ? theme.formInputTextHighlight : 'inherit', - margin: 5, - marginRight: 0, - }} - /> - } - value={text} - onChangeValue={text => { - setText(text); - onSearch(text); - }} - placeholder={`Search ${accountName}`} - style={{ - backgroundColor: theme.tableBackground, - border: `1px solid ${theme.formInputBorder}`, - flex: 1, - height: styles.mobileMinHeight, - }} - /> - </View> - ); -} - -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, - failed, - prependTransactions, - transactions, - accounts, - categories, - payees, - balance, - balanceCleared, - balanceUncleared, - isNewTransaction, - onLoadMore, - onSearch, - onSelectTransaction, -}) { - const allTransactions = useMemo(() => { - return prependTransactions.concat(transactions); - }, [prependTransactions, transactions]); - - const dispatch = useDispatch(); - const onRefresh = async () => { - await dispatch(syncAndDownload(account.id)); - }; - - return ( - <Page - title={ - <AccountName account={account} pending={pending} failed={failed} /> - } - headerLeftContent={<MobileBackButton />} - headerRightContent={ - <Link - variant="button" - to="transactions/new" - type="bare" - aria-label="Add Transaction" - style={{ - justifyContent: 'center', - color: theme.mobileHeaderText, - margin: 10, - }} - hoveredStyle={{ - color: theme.mobileHeaderText, - background: theme.mobileHeaderTextHover, - }} - activeStyle={{ background: 'transparent' }} - > - <SvgAdd width={20} height={20} /> - </Link> - } - padding={0} - style={{ - flex: 1, - backgroundColor: theme.mobilePageBackground, - }} - > - <View - style={{ - alignItems: 'center', - flexShrink: 0, - marginTop: 10, - }} - > - <View - style={{ - flexDirection: 'row', - boxSizing: 'content-box', - width: '100%', - justifyContent: 'space-evenly', - }} - > - <View - style={{ - visibility: - useSheetValue(balanceUncleared) === 0 ? 'hidden' : 'visible', - width: '33%', - }} - > - <Label - title="CLEARED" - style={{ textAlign: 'center', fontSize: 12 }} - /> - <CellValue - binding={balanceCleared} - type="financial" - style={{ - fontSize: 12, - textAlign: 'center', - fontWeight: '500', - }} - data-testid="account-balance-cleared" - /> - </View> - <View style={{ width: '33%' }}> - <Label title="BALANCE" style={{ textAlign: 'center' }} /> - <CellValue - binding={balance} - type="financial" - style={{ - fontSize: 18, - textAlign: 'center', - fontWeight: '500', - }} - getStyle={value => ({ - color: value < 0 ? theme.errorText : theme.pillTextHighlighted, - })} - data-testid="account-balance" - /> - </View> - <View - style={{ - visibility: - useSheetValue(balanceUncleared) === 0 ? 'hidden' : 'visible', - width: '33%', - }} - > - <Label - title="UNCLEARED" - style={{ textAlign: 'center', fontSize: 12 }} - /> - <CellValue - binding={balanceUncleared} - type="financial" - style={{ - fontSize: 12, - textAlign: 'center', - fontWeight: '500', - }} - data-testid="account-balance-uncleared" - /> - </View> - </View> - <TransactionSearchInput - accountName={account.name} - onSearch={onSearch} - /> - </View> - <PullToRefresh onRefresh={onRefresh}> - <TransactionList - account={account} - transactions={allTransactions} - categories={categories} - accounts={accounts} - payees={payees} - isNew={isNewTransaction} - onLoadMore={onLoadMore} - onSelect={onSelectTransaction} - /> - </PullToRefresh> - </Page> - ); -} 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 0000000000000000000000000000000000000000..cf9c1c2e2781d945efe4a6bf3c074adf18e03790 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.jsx @@ -0,0 +1,261 @@ +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, + openAccountCloseModal, + pushModal, + reopenAccount, + syncAndDownload, + updateAccount, +} 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, send } from 'loot-core/platform/client/fetch'; +import { isPreviewId } 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 { styles, theme } from '../../../style'; +import { Text } from '../../common/Text'; +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 }) { + return ( + <Page + title={ + <AccountName account={account} pending={pending} failed={failed} /> + } + headerLeftContent={<MobileBackButton />} + headerRightContent={<AddTransactionButton accountId={account.id} />} + padding={0} + style={{ + flex: 1, + backgroundColor: theme.mobilePageBackground, + }} + > + <SchedulesProvider transform={getSchedulesTransform(account.id)}> + <TransactionListWithPreviews account={account} /> + </SchedulesProvider> + </Page> + ); +} + +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> + ); +} + +const getSchedulesTransform = memoizeOne(id => { + const filter = queries.getAccountFilter(id, '_account'); + + return q => { + q = q.filter({ $and: [filter, { '_account.closed': false }] }); + return q.orderBy({ next_date: 'desc' }); + }; +}); + +function TransactionListWithPreviews({ account }) { + const [currentQuery, setCurrentQuery] = useState(); + const [isSearching, setIsSearching] = useState(false); + const [transactions, setTransactions] = useState([]); + const prependTransactions = usePreviewTransactions(); + const allTransactions = useMemo( + () => + !isSearching ? prependTransactions.concat(transactions) : transactions, + [isSearching, 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 }, + ); + }, []); + + const fetchTransactions = useCallback(() => { + const query = makeRootQuery(); + setCurrentQuery(query); + updateQuery(query); + }, [makeRootQuery, updateQuery]); + + useEffect(() => { + const 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()); + } + } + }); + + fetchTransactions(); + dispatch(markAccountRead(account.id)); + 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, + ), + ); + } + + setIsSearching(searchText !== ''); + }, + [currentQuery, dateFormat, updateQuery], + ), + 150, + ); + + 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 onLoadMore = () => { + paged.current?.fetchNext(); + }; + + const balance = queries.accountBalance(account); + const balanceCleared = queries.accountBalanceCleared(account); + const balanceUncleared = queries.accountBalanceUncleared(account); + + return ( + <TransactionListWithBalances + transactions={allTransactions} + balance={balance} + balanceCleared={balanceCleared} + balanceUncleared={balanceUncleared} + onLoadMore={onLoadMore} + searchPlaceholder={`Search ${account.name}`} + onSearch={onSearch} + onSelectTransaction={onSelectTransaction} + onRefresh={onRefresh} + /> + ); +} diff --git a/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx b/packages/desktop-client/src/components/mobile/accounts/Accounts.jsx index 9f75e6d9ba1b4050cbab49eb5506d7115af87fc2..3ecc0867080434cf412204a726e091f56b1cd021 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} /> </View> diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx index 5b8798d439c8f7950544685f88f9de17bc212190..4eb5cad56261600eaf4b083dd73d153776783bf2 100644 --- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx @@ -8,6 +8,7 @@ import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries'; import * as monthUtils from 'loot-core/src/shared/months'; import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useNavigate } from '../../../hooks/useNavigate'; import { SingleActiveEditFormProvider, useSingleActiveEditForm, @@ -317,6 +318,18 @@ const ExpenseCategory = memo(function ExpenseCategory({ const listItemRef = useRef(); + const _onBudgetAction = (monthIndex, action, arg) => { + onBudgetAction?.( + monthUtils.getMonthFromIndex(monthUtils.getYear(month), monthIndex), + action, + arg, + ); + }; + const navigate = useNavigate(); + const onShowActivity = () => { + navigate(`/categories/${category.id}?month=${month}`); + }; + const content = ( <ListItem style={{ @@ -379,10 +392,12 @@ const ExpenseCategory = memo(function ExpenseCategory({ binding={spent} style={{ ...styles.smallText, + ...styles.underlinedText, textAlign: 'right', }} getStyle={makeAmountGrey} type="financial" + onClick={onShowActivity} /> </View> <View @@ -1398,8 +1413,7 @@ function MonthSelector({ month, monthBounds, onPrevMonth, onNextMonth }) { fontWeight: 500, }} > - {/* eslint-disable-next-line rulesdir/typography */} - {monthUtils.format(month, "MMMM ''yy")} + {monthUtils.format(month, 'MMMM ‘yy')} </Text> <Button type="bare" diff --git a/packages/desktop-client/src/components/mobile/budget/Category.tsx b/packages/desktop-client/src/components/mobile/budget/Category.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da31db63e336b7e438cb3c6043c9787b168ad14d --- /dev/null +++ b/packages/desktop-client/src/components/mobile/budget/Category.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; + +import * as monthUtils from 'loot-core/src/shared/months'; + +import { useCategories } from '../../../hooks/useCategories'; +import { useLocalPref } from '../../../hooks/useLocalPref'; +import { useSetThemeColor } from '../../../hooks/useSetThemeColor'; +import { theme } from '../../../style'; + +import { CategoryTransactions } from './CategoryTransactions'; + +export function Category() { + useSetThemeColor(theme.mobileViewTheme); + const [_numberFormat] = useLocalPref('numberFormat'); + const numberFormat = _numberFormat || 'comma-dot'; + const [hideFraction = false] = useLocalPref('hideFraction'); + + const { id: categoryId } = useParams(); + const [searchParams] = useSearchParams(); + const month = searchParams.get('month') || monthUtils.currentMonth(); + const { list: categories } = useCategories(); + const category = categories.find(c => c.id === categoryId); + + if (category == null) { + return null; + } + + return ( + <CategoryTransactions + // This key forces the whole table rerender when the number + // format changes + key={numberFormat + hideFraction} + category={category} + month={month} + /> + ); +} 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 0000000000000000000000000000000000000000..620a886ad8bfc268e4d72c40f0fb05bf44d7bb21 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx @@ -0,0 +1,156 @@ +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 * as monthUtils from 'loot-core/shared/months'; +import { q } from 'loot-core/shared/query'; +import { isPreviewId } 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 { TextOneLine } from '../../common/TextOneLine'; +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 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 }, + ); + }, []); + + 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, + ), + ); + } + }, + [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 ( + <Page + title={ + <View> + <TextOneLine>{category.name}</TextOneLine> + <TextOneLine>({monthUtils.format(month, 'MMMM ‘yy')})</TextOneLine> + </View> + } + headerLeftContent={<MobileBackButton />} + headerRightContent={<AddTransactionButton categoryId={category.id} />} + padding={0} + style={{ + flex: 1, + backgroundColor: theme.mobilePageBackground, + }} + > + <TransactionListWithBalances + transactions={transactions} + balance={balance} + balanceCleared={balanceCleared} + balanceUncleared={balanceUncleared} + searchPlaceholder={`Search ${category.name}`} + onSearch={onSearch} + onLoadMore={onLoadMore} + onSelectTransaction={onSelectTransaction} + /> + </Page> + ); +} + +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 0000000000000000000000000000000000000000..a37822163f2b41c374ed6e171986ddb9eb438854 --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/AddTransactionButton.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { useNavigate } from '../../../hooks/useNavigate'; +import { SvgAdd } from '../../../icons/v1'; +import { theme } from '../../../style'; +import { Button } from '../../common/Button'; + +type AddTransactionButtonProps = { + to: string; + accountId?: string; + categoryId?: string; +}; + +export function AddTransactionButton({ + to = '/transactions/new', + accountId, + categoryId, +}: AddTransactionButtonProps) { + const navigate = useNavigate(); + return ( + <Button + type="bare" + aria-label="Add transaction" + style={{ + justifyContent: 'center', + color: theme.mobileHeaderText, + margin: 10, + }} + hoveredStyle={{ + color: theme.mobileHeaderText, + background: theme.mobileHeaderTextHover, + }} + onClick={() => { + navigate(to, { state: { accountId, categoryId } }); + }} + > + <SvgAdd width={20} height={20} /> + </Button> + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx b/packages/desktop-client/src/components/mobile/transactions/ListBox.jsx index 028c1787e3eaa251f43376ec13ad425eb345ab99..402a950d8d337582177f58a3a71a880690a0ac08 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 055239cc7d6ddcd4687e2d0904f3b04927db4dfd..855c7f44a63382d24fc1acc1493f93f75a4d858e 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) && ( + <SvgSplit + style={{ + width: 12, + height: 12, + marginRight: 5, + }} + /> + )} <TextOneLine style={{ fontSize: 11, diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index 42be12c67ea85e0e5e869cf9651f74803d990cb2..b800f9595ccabeb9b1ef33122e1ea515d8bbab62 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -7,7 +7,7 @@ import React, { useMemo, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { format as formatDate, @@ -56,9 +56,9 @@ import { styles, theme } from '../../../style'; import { Button } from '../../common/Button'; import { Text } from '../../common/Text'; import { View } from '../../common/View'; -import { MobileBackButton } from '../../MobileBackButton'; import { Page } from '../../Page'; import { AmountInput } from '../../util/AmountInput'; +import { MobileBackButton } from '../MobileBackButton'; import { FieldLabel, TapField, InputField, BooleanField } from '../MobileForms'; import { FocusableAmountInput } from './FocusableAmountInput'; @@ -947,21 +947,28 @@ function isTemporary(transaction) { return transaction.id.indexOf('temp') === 0; } -function makeTemporaryTransactions(currentAccountId, lastDate) { +function makeTemporaryTransactions(accountId, categoryId, lastDate) { return [ { id: 'temp', date: lastDate || monthUtils.currentDay(), - account: currentAccountId, + account: accountId, + category: categoryId, amount: 0, cleared: false, }, ]; } -function TransactionEditUnconnected(props) { - const { categories, accounts, payees, lastTransaction, dateFormat } = props; - const { id: accountId, transactionId } = useParams(); +function TransactionEditUnconnected({ + categories, + accounts, + payees, + lastTransaction, + dateFormat, +}) { + const { transactionId } = useParams(); + const { state: locationState } = useLocation(); const navigate = useNavigate(); const dispatch = useDispatch(); const [transactions, setTransactions] = useState([]); @@ -991,7 +998,7 @@ function TransactionEditUnconnected(props) { setTransactions(fetchedTransactions); setFetchedTransactions(fetchedTransactions); } - if (transactionId) { + if (transactionId !== 'new') { fetchTransaction(); } else { adding.current = true; @@ -1002,12 +1009,13 @@ function TransactionEditUnconnected(props) { if (adding.current) { setTransactions( makeTemporaryTransactions( - accountId || lastTransaction?.account || null, + locationState?.accountId || lastTransaction?.account || null, + locationState?.categoryId || lastTransaction?.category || null, lastTransaction?.date, ), ); } - }, [accountId, lastTransaction]); + }, [locationState?.accountId, locationState?.categoryId, lastTransaction]); if ( categories.length === 0 || diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx index e76fe396d028bc56b9c605a27685ba3d1c5d8a06..332d45c1002dba3bede8b241f1718fdec06b6f4b 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx @@ -12,10 +12,6 @@ import { ListBox } from './ListBox'; import { Transaction } from './Transaction'; export function TransactionList({ - account, - accounts, - categories, - payees, transactions, isNew, onSelect, @@ -45,9 +41,7 @@ export function TransactionList({ }); } - if (!transaction.is_child) { - sections[sections.length - 1].data.push(transaction); - } + sections[sections.length - 1].data.push(transaction); }); return sections; }, [transactions]); @@ -98,12 +92,8 @@ export function TransactionList({ > <Transaction transaction={transaction} - account={account} - categories={categories} - accounts={accounts} - payees={payees} added={isNew(transaction.id)} - onSelect={onSelect} // onSelect(transaction)} + onSelect={onSelect} /> </Item> ); 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 0000000000000000000000000000000000000000..1eddb932d2fac53cde303f5883e8b92127360ac4 --- /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 ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.mobilePageBackground, + padding: 10, + width: '100%', + }} + > + <InputWithContent + leftContent={ + <SvgSearchAlternate + style={{ + width: 13, + height: 13, + flexShrink: 0, + color: text ? theme.formInputTextHighlight : 'inherit', + margin: 5, + marginRight: 0, + }} + /> + } + value={text} + onChangeValue={text => { + setText(text); + onSearch(text); + }} + placeholder={placeholder} + style={{ + backgroundColor: theme.tableBackground, + border: `1px solid ${theme.formInputBorder}`, + flex: 1, + height: styles.mobileMinHeight, + }} + /> + </View> + ); +} + +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 ( + <> + <View + style={{ + flexShrink: 0, + marginTop: 10, + }} + > + <View + style={{ + flexDirection: 'row', + justifyContent: 'space-evenly', + }} + > + <View + style={{ + display: !unclearedAmount ? 'none' : undefined, + flexBasis: '33%', + }} + > + <Label + title="CLEARED" + style={{ textAlign: 'center', fontSize: 12 }} + /> + <CellValue + binding={balanceCleared} + type="financial" + style={{ + fontSize: 12, + textAlign: 'center', + fontWeight: '500', + }} + data-testid="transactions-balance-cleared" + /> + </View> + <View style={{ flexBasis: '33%' }}> + <Label title="BALANCE" style={{ textAlign: 'center' }} /> + <CellValue + binding={balance} + type="financial" + style={{ + fontSize: 18, + textAlign: 'center', + fontWeight: '500', + }} + getStyle={value => ({ + color: value < 0 ? theme.errorText : theme.pillTextHighlighted, + })} + data-testid="transactions-balance" + /> + </View> + <View + style={{ + display: !unclearedAmount ? 'none' : undefined, + flexBasis: '33%', + }} + > + <Label + title="UNCLEARED" + style={{ textAlign: 'center', fontSize: 12 }} + /> + <CellValue + binding={balanceUncleared} + type="financial" + style={{ + fontSize: 12, + textAlign: 'center', + fontWeight: '500', + }} + data-testid="transactions-balance-uncleared" + /> + </View> + </View> + <TransactionSearchInput + placeholder={searchPlaceholder} + onSearch={onSearch} + /> + </View> + <PullToRefresh isPullable={!!onRefresh} onRefresh={onRefresh}> + <TransactionList + transactions={transactions} + isNew={isNewTransaction} + onLoadMore={onLoadMore} + onSelect={onSelectTransaction} + /> + </PullToRefresh> + </> + ); +} diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f4f57b3ef02c4db93fb84b5247e0987371ae894 --- /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 9902efac09c72eb8226c8e2636bd2f997af6abc5..2f6117dea84da4984e0b5dd33fc328185989ea6a 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<UseSchedulesReturnType>(null); +}: UseSchedulesArgs = {}): UseSchedulesResult { + const [data, setData] = useState<UseSchedulesResult>(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<SchedulesContextValue | undefined>( + 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 f8ac400a04ef1895b7cee5d57ebc7ab418c02c13..d4320ee52be9308635ba98ac8fd4032a2a1d792c 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 0000000000000000000000000000000000000000..2dbc168090e7782d0c30e11a76f6b57b0ec7bc35 --- /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.