diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 3a5fe298f27..80f24e14d2d 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -1,19 +1,38 @@ // @ts-strict-ignore import React, { - PureComponent, type MutableRefObject, - createRef, useMemo, - type ReactElement, + useRef, + useState, + useEffect, + useCallback, } from 'react'; import { Trans } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Navigate, useParams, useLocation } from 'react-router-dom'; -import { debounce } from 'debounce'; import { t } from 'i18next'; +import { useDebounceCallback } from 'usehooks-ts'; import { v4 as uuidv4 } from 'uuid'; +import { + addNotification, + createPayee, + initiallyLoadPayees, + markAccountRead, + openAccountCloseModal, + pushModal, + reopenAccount, + replaceModal, + setLastUndoState, + syncAndDownload, + unlinkAccount, + updateAccount, +} from 'loot-core/client/actions'; +import { + usePreviewTransactions, + useTransactions, +} from 'loot-core/client/data-hooks/transactions'; import { validForTransfer } from 'loot-core/client/transfer'; import { type UndoState } from 'loot-core/server/undo'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; @@ -22,14 +41,10 @@ import { defaultSchedulesQueryBuilder, } from 'loot-core/src/client/data-hooks/schedules'; import * as queries from 'loot-core/src/client/queries'; -import { - runQuery, - pagedQuery, - type PagedQuery, -} from 'loot-core/src/client/query-helpers'; +import { runQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { currentDay } from 'loot-core/src/shared/months'; -import { q, type Query } from 'loot-core/src/shared/query'; +import { type Query } from 'loot-core/src/shared/query'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { updateTransaction, @@ -44,23 +59,22 @@ import { type NewRuleEntity, type RuleActionEntity, type AccountEntity, - type PayeeEntity, type RuleConditionEntity, type TransactionEntity, type TransactionFilterEntity, } from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; -import { useActions } from '../../hooks/useActions'; import { useCategories } from '../../hooks/useCategories'; 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 { usePrevious } from '../../hooks/usePrevious'; import { - SelectedProviderWithItems, - type Actions, + SelectedProvider, + useSelected, + useSelectedDispatch, } from '../../hooks/useSelected'; import { SplitsExpandedProvider, @@ -137,91 +151,6 @@ function EmptyMessage({ onAdd }: EmptyMessageProps) { ); } -type AllTransactionsProps = { - account?: AccountEntity; - transactions: TransactionEntity[]; - balances: Record | null; - showBalances?: boolean; - filtered?: boolean; - children: ( - transactions: TransactionEntity[], - balances: Record | null, - ) => ReactElement; - collapseTransactions: (ids: string[]) => void; -}; - -function AllTransactions({ - account, - transactions, - balances, - showBalances, - filtered, - children, - collapseTransactions, -}: AllTransactionsProps) { - const accountId = account?.id; - const prependTransactions: (TransactionEntity & { _inverse?: boolean })[] = - usePreviewTransactions(collapseTransactions).map(trans => ({ - ...trans, - _inverse: accountId ? accountId !== trans.account : false, - })); - - transactions ??= []; - - let runningBalance = useMemo(() => { - if (!showBalances) { - return 0; - } - - return balances && transactions?.length > 0 - ? (balances[transactions[0].id]?.balance ?? 0) - : 0; - }, [showBalances, balances, transactions]); - - const prependBalances = useMemo(() => { - if (!showBalances) { - return null; - } - - // Reverse so we can calculate from earliest upcoming schedule. - const scheduledBalances = [...prependTransactions] - .reverse() - .map(scheduledTransaction => { - const amount = - (scheduledTransaction._inverse ? -1 : 1) * - getScheduledAmount(scheduledTransaction.amount); - return { - // TODO: fix me - // eslint-disable-next-line react-hooks/exhaustive-deps - balance: (runningBalance += amount), - id: scheduledTransaction.id, - }; - }); - return groupById(scheduledBalances); - }, [showBalances, prependTransactions, runningBalance]); - - const allTransactions = useMemo(() => { - // Don't prepend scheduled transactions if we are filtering - if (!filtered && prependTransactions.length > 0) { - return prependTransactions.concat(transactions); - } - return transactions; - }, [filtered, prependTransactions, transactions]); - - const allBalances = useMemo(() => { - // Don't prepend scheduled transactions if we are filtering - if (!filtered && prependBalances && balances) { - return { ...prependBalances, ...balances }; - } - return balances; - }, [filtered, prependBalances, balances]); - - if (!prependTransactions?.length || filtered) { - return children(transactions, balances); - } - return children(allTransactions, allBalances); -} - function getField(field?: string) { if (!field) { return 'date'; @@ -243,136 +172,436 @@ function getField(field?: string) { } } +function getAccountTitle( + account?: AccountEntity, + id?: string, + filterName?: string, +) { + if (filterName) { + return filterName; + } + + if (!account) { + if (id === 'budgeted') { + return t('Budgeted Accounts'); + } else if (id === 'offbudget') { + return t('Off Budget Accounts'); + } else if (id === 'uncategorized') { + return t('Uncategorized'); + } else if (!id) { + return t('All Accounts'); + } + return null; + } + + return account.name; +} + type AccountInternalProps = { accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized'; - filterConditions: RuleConditionEntity[]; - showBalances?: boolean; - setShowBalances: (newValue: boolean) => void; - showCleared?: boolean; - setShowCleared: (newValue: boolean) => void; - showReconciled: boolean; - setShowReconciled: (newValue: boolean) => void; - showExtraBalances?: boolean; - setShowExtraBalances: (newValue: boolean) => void; - modalShowing?: boolean; - setLastUndoState: (state: null) => void; - lastUndoState: { current: UndoState | null }; - accounts: AccountEntity[]; - getPayees: () => Promise; - updateAccount: (newAccount: AccountEntity) => void; - newTransactions: string[]; - matchedTransactions: string[]; - splitsExpandedDispatch: ReturnType['dispatch']; - expandSplits?: boolean; - savedFilters: TransactionFilterEntity[]; - onBatchEdit: ReturnType['onBatchEdit']; - onBatchDuplicate: ReturnType< - typeof useTransactionBatchActions - >['onBatchDuplicate']; - onBatchLinkSchedule: ReturnType< - typeof useTransactionBatchActions - >['onBatchLinkSchedule']; - onBatchUnlinkSchedule: ReturnType< - typeof useTransactionBatchActions - >['onBatchUnlinkSchedule']; - onBatchDelete: ReturnType['onBatchDelete']; categoryId?: string; - location: ReturnType; - failedAccounts: ReturnType; - dateFormat: ReturnType; - payees: ReturnType; - categoryGroups: ReturnType['grouped']; - hideFraction: boolean; - accountsSyncing: string[]; -} & ReturnType; -type AccountInternalState = { - search: string; - filterConditions: ConditionEntity[]; - filterId?: SavedFilter; - filterConditionsOp: 'and' | 'or'; - loading: boolean; - workingHard: boolean; - reconcileAmount: null | number; - transactions: TransactionEntity[]; - transactionCount: number; - transactionsFiltered?: boolean; - showBalances?: boolean; - balances: Record | null; - showCleared?: boolean; - prevShowCleared?: boolean; - showReconciled: boolean; - editingName: boolean; - nameError: string; - isAdding: boolean; - modalShowing?: boolean; - sort: { - ascDesc: 'asc' | 'desc'; - field: string; - prevField?: string; - prevAscDesc?: 'asc' | 'desc'; - } | null; - filteredAmount: null | number; }; -export type TableRef = MutableRefObject<{ +type TableRefProps = { edit: (updatedId: string | null, op?: string, someBool?: boolean) => void; setRowAnimation: (animation: boolean) => void; scrollTo: (focusId: string) => void; scrollToTop: () => void; getScrolledItem: () => string; -} | null>; - -class AccountInternal extends PureComponent< - AccountInternalProps, - AccountInternalState -> { - paged: PagedQuery | null; - rootQuery: Query; - currentQuery: Query; - table: TableRef; - unlisten?: () => void; - dispatchSelected?: (action: Actions) => void; - - constructor(props: AccountInternalProps) { - super(props); - this.paged = null; - this.table = createRef(); - - this.state = { - search: '', - filterConditions: props.filterConditions || [], - filterId: undefined, - filterConditionsOp: 'and', - loading: true, - workingHard: false, - reconcileAmount: null, - transactions: [], - transactionCount: 0, - showBalances: props.showBalances, - balances: null, - showCleared: props.showCleared, - showReconciled: props.showReconciled, - editingName: false, - nameError: '', - isAdding: false, - sort: null, - filteredAmount: null, +}; +export type TableRef = MutableRefObject; + +type SortOptions = { + ascDesc: 'asc' | 'desc'; + field: string; + prevField?: string; + prevAscDesc?: 'asc' | 'desc'; +}; + +function AccountInternal({ accountId, categoryId }: AccountInternalProps) { + const { grouped: categoryGroups } = useCategories(); + const payees = usePayees(); + const accounts = useAccounts(); + const location = useLocation(); + const savedFilters = useFilters(); + const dispatch = useDispatch(); + const { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); + const params = useParams(); + + const newTransactions = useSelector(state => state.queries.newTransactions); + const matchedTransactions = useSelector( + state => state.queries.matchedTransactions, + ); + const failedAccounts = useFailedAccounts(); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + const [hideFractionPref] = useSyncedPref('hideFraction'); + const [hideFraction] = useState(Boolean(hideFractionPref)); + const [showBalancesPref, setShowBalancesPref] = useSyncedPref( + `show-balances-${params.id}`, + ); + const [showBalances, setShowBalances] = useState(Boolean(showBalancesPref)); + const [hideClearedPref, setHideClearedPref] = useSyncedPref( + `hide-cleared-${params.id}`, + ); + const [hideCleared, setHideCleared] = useState(Boolean(hideClearedPref)); + const previousHideCleared = usePrevious(hideCleared); + const [hideReconciledPref, setHideReconciledPref] = useSyncedPref( + `hide-reconciled-${params.id}`, + ); + const [hideReconciled, setHideReconciled] = useState( + Boolean(hideReconciledPref), + ); + const [showExtraBalancesPref, setShowExtraBalancesPref] = useSyncedPref( + `show-extra-balances-${params.id || 'all-accounts'}`, + ); + const [showExtraBalances, setShowExtraBalances] = useState( + Boolean(showExtraBalancesPref), + ); + const modalShowing = useSelector(state => state.modals.modalStack.length > 0); + const accountsSyncing = useSelector(state => state.account.accountsSyncing); + const lastUndoState = useSelector(state => state.app.lastUndoState); + + // const savedFiters = useFilters(); + const tableRef = useRef(null); + const dispatchSelected = useSelectedDispatch(); + const { + onBatchDelete, + onBatchDuplicate, + onBatchEdit, + onBatchLinkSchedule, + onBatchUnlinkSchedule, + } = useTransactionBatchActions(); + + const [isSearching, setIsSearching] = useState(false); + const [filterConditions, setFilterConditions] = useState( + location?.state?.filterConditions || [], + ); + const [filterId, setFilterId] = useState(); + const [filterConditionsOp, setFilterConditionsOp] = useState<'and' | 'or'>( + 'and', + ); + const [reconcileAmount, setReconcileAmount] = useState(null); + const [runningBalances, setRunningBalances] = useState | null>(null); + const [editingName, setEditingName] = useState(false); + const [nameError, setNameError] = useState(null); + const [isAdding, setIsAdding] = useState(false); + const [sort, setSort] = useState(null); + // const [filteredAmount, setFilteredAmount] = useState(null); + const [isProcessingTransactions, setIsProcessingTransactions] = + useState(false); + const [transactionsQuery, setTransactionsQuery] = useState( + undefined, + ); + + const applySort = useCallback( + (query: Query, { field, ascDesc, prevField, prevAscDesc }: SortOptions) => { + const isFiltered = filterConditions.length > 0; + const sortField = getField(!field ? sort?.field : field); + const sortAscDesc = !ascDesc ? sort?.ascDesc : ascDesc; + const sortPrevField = getField(!prevField ? sort?.prevField : prevField); + const sortPrevAscDesc = !prevField ? sort?.prevAscDesc : prevAscDesc; + + const sortQuery = ( + query: Query, + sortField: string, + sortAscDesc?: 'asc' | 'desc', + ) => { + if (sortField === 'cleared') { + query = query.orderBy({ + reconciled: sortAscDesc, + }); + } + + return query.orderBy({ + [sortField]: sortAscDesc, + }); + }; + + const sortNoActiveFiltersQuery = ( + query: Query, + sortField: string, + sortAscDesc?: 'asc' | 'desc', + ) => { + if (sortField === 'cleared') { + return query + .orderBy({ + reconciled: sortAscDesc, + }) + .orderBy({ + cleared: sortAscDesc, + }); + } + + return query.orderBy({ + [sortField]: sortAscDesc, + }); + }; + + // sort by previously used sort field, if any + const maybeSortByPreviousField = ( + query: Query, + sortPrevField: string, + sortPrevAscDesc?: 'asc' | 'desc', + ) => { + if (!sortPrevField) { + return query; + } + + if (sortPrevField === 'cleared') { + query = query.orderBy({ + reconciled: sortPrevAscDesc, + }); + } + + return query.orderBy({ + [sortPrevField]: sortPrevAscDesc, + }); + }; + + switch (true) { + // called by applyFilters to sort an already filtered result + case !field: + query = sortQuery(query, sortField, sortAscDesc); + break; + + // called directly from UI by sorting a column. + // active filters need to be applied before sorting + case isFiltered: + // TODO: Verify that this is no longer needed. + // this.applyFilters([...filterConditions]); + query = sortQuery(query, sortField, sortAscDesc); + break; + + // called directly from UI by sorting a column. + // no active filters, start a new root query. + case !isFiltered: + query = sortNoActiveFiltersQuery(query, sortField, sortAscDesc); + break; + + default: + break; + } + + return maybeSortByPreviousField(query, sortPrevField, sortPrevAscDesc); + }, + [ + filterConditions.length, + sort?.ascDesc, + sort?.field, + sort?.prevAscDesc, + sort?.prevField, + ], + ); + + const applyFilters = useCallback( + async (query: Query, conditions: ConditionEntity[]) => { + if (conditions.length > 0) { + const filteredCustomQueryFilters: Partial[] = + conditions.filter(cond => !isTransactionFilterEntity(cond)); + const customQueryFilters = filteredCustomQueryFilters.map( + f => f.queryFilter, + ); + const { filters: queryFilters } = await send( + 'make-filters-from-conditions', + { + conditions: conditions.filter( + cond => isTransactionFilterEntity(cond) || !cond.customName, + ), + }, + ); + const conditionsOpKey = filterConditionsOp === 'or' ? '$or' : '$and'; + return query.filter({ + [conditionsOpKey]: [...queryFilters, ...customQueryFilters], + }); + } + + return query; + }, + [filterConditionsOp], + ); + + const baseTransactionsQuery = useCallback( + (options: { accountId: string; hideReconciled: boolean }) => { + let query = queries + .transactions(options.accountId) + .options({ splits: 'grouped' }); + if (options.hideReconciled) { + query = query.filter({ reconciled: { $eq: false } }); + } + return query.select('*'); + }, + [], + ); + + const filteredTransactionsQuery = useCallback( + async (query: Query, filterConditions: ConditionEntity[]) => { + return await applyFilters(query, filterConditions); + }, + [applyFilters], + ); + + const sortedTransactionsQuery = useCallback( + async (query: Query, sort: SortOptions) => { + return sort ? applySort(query, sort) : query; + }, + [applySort], + ); + + const rootTransactionsQuery = useCallback( + async () => + sortedTransactionsQuery( + await filteredTransactionsQuery( + baseTransactionsQuery({ + accountId, + hideReconciled, + }), + filterConditions, + ), + sort, + ), + [ + sortedTransactionsQuery, + filteredTransactionsQuery, + baseTransactionsQuery, + accountId, + hideReconciled, + filterConditions, + sort, + ], + ); + + // Doesn't depend on current transactions, but total unfiltered account balance. + const accountBalanceQuery = useMemo( + () => + baseTransactionsQuery({ + accountId, + hideReconciled: false, + }).calculate({ $sum: '$amount' }), + [accountId, baseTransactionsQuery], + ); + + const { data: previewTransactions } = usePreviewTransactions(); + const previewTransactionsWithInverse: (TransactionEntity & { + _inverse?: boolean; + })[] = useMemo( + () => + previewTransactions.map(trans => ({ + ...trans, + _inverse: accountId ? accountId !== trans.account : false, + })), + [accountId, previewTransactions], + ); + + // TODO: Enhance SplitsExpandedProvider to always collapse certain IDs on load. + useEffect(() => { + splitsExpandedDispatch({ + type: 'close-splits', + ids: previewTransactions.map(t => t.id), + }); + }, [previewTransactions, splitsExpandedDispatch]); + + const calculateRunningBalances = useCallback(async () => { + const { data: balances } = await runQuery( + queries + .transactions(accountId) + .options({ splits: 'none' }) + .orderBy({ date: 'desc' }) + .select([{ balance: { $sumOver: '$amount' } }]), + ); + + const latestBalance = balances[0]?.balance ?? 0; + + const previewBalancesById = previewTransactionsWithInverse.reduce( + (map, trans, index, array) => { + map[trans.id] = { + balance: array + .slice(index, array.length) + .reduce( + (sum, t) => sum + getScheduledAmount(t.amount), + latestBalance, + ), + }; + return map; + }, + {}, + ); + const balancesById = groupById<{ id: string; balance: number }>(balances); + + return { + ...previewBalancesById, + ...balancesById, }; - } + }, [accountId, previewTransactionsWithInverse]); + + useEffect(() => { + let isUnmounted = false; + + async function initRunningBalances() { + const balances = await calculateRunningBalances(); + if (!isUnmounted) { + setRunningBalances(balances); + } + } + + initRunningBalances(); + + return () => { + isUnmounted = true; + }; + }, [calculateRunningBalances]); + + const { + isLoading: isTransactionsLoading, + transactions: transactionsGrouped, + reload: reloadTransactions, + loadMore: loadMoreTransactions, + } = useTransactions({ + query: transactionsQuery, + options: { pageCount: 150 }, + }); + + const transactions = useMemo( + () => ungroupTransactions(transactionsGrouped), + [transactionsGrouped], + ); - async componentDidMount() { - const maybeRefetch = (tables: string[]) => { + useEffect(() => { + let isUnmounted = false; + + async function initQuery() { + const rootQuery = await rootTransactionsQuery(); + if (!isUnmounted) { + setTransactionsQuery(rootQuery); + } + } + + initQuery(); + + return () => { + isUnmounted = true; + }; + }, [rootTransactionsQuery]); + + useEffect(() => { + dispatch(initiallyLoadPayees()); + + if (accountId) { + dispatch(markAccountRead(accountId)); + } + }, [accountId, dispatch]); + + useEffect(() => { + const onUndo = async ({ tables, messages }: UndoState) => { if ( tables.includes('transactions') || tables.includes('category_mapping') || tables.includes('payee_mapping') ) { - return this.refetchTransactions(); + reloadTransactions?.(); } - }; - - const onUndo = async ({ tables, messages }: UndoState) => { - await maybeRefetch(tables); // If all the messages are dealing with transactions, find the // first message referencing a non-deleted row so that we can @@ -394,224 +623,96 @@ class AccountInternal extends PureComponent< // this.table && this.table.highlight(focusableMsgs.map(msg => msg.row)); } - if (this.table.current) { - this.table.current.edit(null); + if (tableRef.current) { + tableRef.current.edit(null); // Focus a transaction if applicable. There is a chance if the // user navigated away that focusId is a transaction that has // been "paged off" and we won't focus it. That's ok, we just // do our best. if (focusId) { - this.table.current.scrollTo(focusId); + tableRef.current.scrollTo(focusId); } } - this.props.setLastUndoState(null); - }; - - const unlistens = [listen('undo-event', onUndo)]; - - this.unlisten = () => { - unlistens.forEach(unlisten => unlisten()); + dispatch(setLastUndoState(null)); }; - // Important that any async work happens last so that the - // listeners are set up synchronously - await this.props.initiallyLoadPayees(); - await this.fetchTransactions(this.state.filterConditions); - // If there is a pending undo, apply it immediately (this happens // when an undo changes the location to this page) - if (this.props.lastUndoState && this.props.lastUndoState.current) { - onUndo(this.props.lastUndoState.current); - } - } - - componentDidUpdate(prevProps: AccountInternalProps) { - // If the active account changes - close the transaction entry mode - if (this.state.isAdding && this.props.accountId !== prevProps.accountId) { - this.setState({ isAdding: false }); - } - - // If the user was on a different screen and is now coming back to - // the transactions, automatically refresh the transaction to make - // sure we have updated state - if (prevProps.modalShowing && !this.props.modalShowing) { - // This is clearly a hack. Need a better way to track which - // things are listening to transactions and refetch - // automatically (use ActualQL?) - setTimeout(() => { - this.refetchTransactions(); - }, 100); - } - - //Resest sort/filter/search on account change - if (this.props.accountId !== prevProps.accountId) { - this.setState({ sort: null, search: '', filterConditions: [] }); - } - } - - componentWillUnmount() { - if (this.unlisten) { - this.unlisten(); - } - if (this.paged) { - this.paged.unsubscribe(); - } - } - - fetchAllIds = async () => { - const { data } = await runQuery(this.paged?.query.select('id')); - // Remember, this is the `grouped` split type so we need to deal - // with the `subtransactions` property - return data.reduce((arr: string[], t: TransactionEntity) => { - arr.push(t.id); - t.subtransactions?.forEach(sub => arr.push(sub.id)); - return arr; - }, []); - }; - - refetchTransactions = async () => { - this.paged?.run(); - }; - - fetchTransactions = (filterConditions?: ConditionEntity[]) => { - const query = this.makeRootTransactionsQuery(); - this.rootQuery = this.currentQuery = query; - if (filterConditions) this.applyFilters(filterConditions); - else this.updateQuery(query); - - if (this.props.accountId) { - this.props.markAccountRead(this.props.accountId); - } - }; - - makeRootTransactionsQuery = () => { - const accountId = this.props.accountId; - - return queries.transactions(accountId); - }; - - updateQuery(query: Query, isFiltered: boolean = false) { - if (this.paged) { - this.paged.unsubscribe(); - } - - // Filter out reconciled transactions if they are hidden - // and we're not showing balances. - if ( - !this.state.showReconciled && - (!this.state.showBalances || !this.canCalculateBalance()) - ) { - query = query.filter({ reconciled: { $eq: false } }); + if (lastUndoState && lastUndoState.current) { + onUndo(lastUndoState.current); } - this.paged = pagedQuery(query.select('*'), { - onData: async (groupedData, prevData) => { - const data = ungroupTransactions([...groupedData]); - const firstLoad = prevData == null; + return listen('undo-event', onUndo); + }, [dispatch, lastUndoState, reloadTransactions]); - if (firstLoad) { - this.table.current?.setRowAnimation(false); + useEffect(() => { + let isUnmounted = false; + const unlisten = listen('sync-event', ({ type, tables }) => { + if (isUnmounted) { + return; + } - if (isFiltered) { - this.props.splitsExpandedDispatch({ - type: 'set-mode', - mode: 'collapse', - }); - } else { - this.props.splitsExpandedDispatch({ - type: 'set-mode', - mode: this.props.expandSplits ? 'expand' : 'collapse', - }); - } + if (type === 'applied') { + if ( + tables.includes('transactions') || + tables.includes('category_mapping') || + tables.includes('payee_mapping') + ) { + reloadTransactions?.(); } - this.setState( - { - transactions: data, - transactionCount: this.paged?.totalCount, - transactionsFiltered: isFiltered, - loading: false, - workingHard: false, - balances: this.state.showBalances - ? await this.calculateBalances() - : null, - filteredAmount: await this.getFilteredAmount(), - }, - () => { - if (firstLoad) { - this.table.current?.scrollToTop(); - } - - setTimeout(() => { - this.table.current?.setRowAnimation(true); - }, 0); - }, - ); - }, - options: { - pageCount: 150, - onlySync: true, - }, + if (tables.includes('transactions')) { + // Recalculate running balances when transactions are updated + calculateRunningBalances().then(setRunningBalances); + } + } }); - } + return () => { + isUnmounted = true; + unlisten(); + }; + }, [calculateRunningBalances, dispatch, reloadTransactions]); - UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) { - if (this.props.accountId !== nextProps.accountId) { - this.setState( - { - editingName: false, - loading: true, - search: '', - showBalances: nextProps.showBalances, - balances: null, - showCleared: nextProps.showCleared, - showReconciled: nextProps.showReconciled, - reconcileAmount: null, - }, - () => { - this.fetchTransactions(); - }, - ); + const wasModalShowing = usePrevious(modalShowing); + useEffect(() => { + // If the user was on a different screen and is now coming back to + // the transactions, automatically refresh the transaction to make + // sure we have updated state + if (wasModalShowing && !modalShowing) { + reloadTransactions?.(); } - } - - onSearch = (value: string) => { - this.paged?.unsubscribe(); - this.setState({ search: value }, this.onSearchDone); - }; + }, [modalShowing, reloadTransactions, wasModalShowing]); + + const updateSearchQuery = useDebounceCallback( + useCallback( + async (searchText: string) => { + if (searchText === '') { + setTransactionsQuery(await rootTransactionsQuery()); + } else if (searchText) { + setTransactionsQuery(currentQuery => + queries.transactionsSearch(currentQuery, searchText, dateFormat), + ); + } - onSearchDone = debounce(() => { - if (this.state.search === '') { - this.updateQuery( - this.currentQuery, - this.state.filterConditions.length > 0, - ); - } else { - this.updateQuery( - queries.transactionsSearch( - this.currentQuery, - this.state.search, - this.props.dateFormat, - ), - true, - ); - } - }, 150); + setIsSearching(searchText !== ''); + }, + [rootTransactionsQuery, dateFormat], + ), + 150, + ); - onSync = async () => { - const accountId = this.props.accountId; - const account = this.props.accounts.find(acct => acct.id === accountId); + const onSearch = useCallback(updateSearchQuery, [updateSearchQuery]); - await this.props.syncAndDownload(account ? account.id : undefined); - }; + const onSync = useCallback(async () => { + const account = accounts.find(acct => acct.id === accountId); - onImport = async () => { - const accountId = this.props.accountId; - const account = this.props.accounts.find(acct => acct.id === accountId); - const categories = await this.props.getCategories(); + await dispatch(syncAndDownload(account ? account.id : undefined)); + }, [accountId, accounts, dispatch]); + const onImport = useCallback(async () => { + const account = accounts.find(acct => acct.id === accountId); if (account) { const res = await window.Actual?.openFileDialog({ filters: [ @@ -623,287 +724,225 @@ class AccountInternal extends PureComponent< }); if (res) { - this.props.pushModal('import-transactions', { - accountId, - categories, - filename: res[0], - onImported: (didChange: boolean) => { - if (didChange) { - this.fetchTransactions(); - } - }, - }); + dispatch( + pushModal('import-transactions', { + accountId, + filename: res[0], + onImported: (didChange: boolean) => { + if (didChange) { + reloadTransactions?.(); + } + }, + }), + ); } } - }; + }, [accountId, accounts, dispatch, reloadTransactions]); - onExport = async (accountName: string) => { - const exportedTransactions = await send('transactions-export-query', { - query: this.currentQuery.serialize(), - }); - const normalizedName = - accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-'); - const filename = `${normalizedName || 'transactions'}.csv`; - - window.Actual?.saveFile( - exportedTransactions, - filename, - t('Export Transactions'), - ); - }; + const onExport = useCallback( + async (accountName: string) => { + const exportedTransactions = await send('transactions-export-query', { + query: transactionsQuery.serialize(), + }); + const normalizedName = + accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-'); + const filename = `${normalizedName || 'transactions'}.csv`; + + window.Actual?.saveFile( + exportedTransactions, + filename, + t('Export Transactions'), + ); + }, + [transactionsQuery], + ); - onTransactionsChange = (updatedTransaction: TransactionEntity) => { - // Apply changes to pagedQuery data - this.paged?.optimisticUpdate(data => { - if (updatedTransaction._deleted) { - return data.filter(t => t.id !== updatedTransaction.id); + // TODO: Can this be replaced with a state? + // const onTransactionsChange = (updatedTransaction: TransactionEntity) => { + // // Apply changes to pagedQuery data + // this.paged?.optimisticUpdate(data => { + // if (updatedTransaction._deleted) { + // return data.filter(t => t.id !== updatedTransaction.id); + // } else { + // return data.map(t => { + // return t.id === updatedTransaction.id ? updatedTransaction : t; + // }); + // } + // }); + + // this.props.updateNewTransactions(updatedTransaction.id); + // }; + + const onAddTransaction = useCallback(() => { + setIsAdding(true); + }, []); + + const onExposeName = useCallback((flag: boolean) => { + setEditingName(flag); + }, []); + + const onSaveName = useCallback( + (name: string) => { + const accountNameError = validateAccountName(name, accountId, accounts); + if (accountNameError) { + setNameError(accountNameError); } else { - return data.map(t => { - return t.id === updatedTransaction.id ? updatedTransaction : t; - }); + const account = accounts.find(account => account.id === accountId); + // TODO: Double check if updateAccount is actually the same. + dispatch(updateAccount({ ...account, name })); + setEditingName(false); + setNameError(''); } + }, + [accountId, accounts, dispatch], + ); + + const onToggleExtraBalances = useCallback(() => { + setShowExtraBalances(show => { + show = !show; + setShowExtraBalancesPref(String(show)); + return show; }); + }, [setShowExtraBalancesPref]); + + const onMenuSelect = useCallback( + async ( + item: + | 'link' + | 'unlink' + | 'close' + | 'reopen' + | 'export' + | 'toggle-balance' + | 'remove-sorting' + | 'toggle-cleared' + | 'toggle-reconciled', + ) => { + const account = accounts.find(account => account.id === accountId)!; + + switch (item) { + case 'link': + dispatch( + pushModal('add-account', { + upgradingAccountId: accountId, + }), + ); + break; + case 'unlink': + dispatch( + pushModal('confirm-unlink-account', { + accountName: account.name, + onUnlink: () => { + dispatch(unlinkAccount(accountId)); + }, + }), + ); + break; + case 'close': + dispatch(openAccountCloseModal(accountId)); + break; + case 'reopen': + dispatch(reopenAccount(accountId)); + break; + case 'export': + const accountName = getAccountTitle( + account, + accountId, + location.state?.filterName, + ); + onExport(accountName); + break; + case 'toggle-balance': + setShowBalances(show => { + show = !show; + setShowBalancesPref(String(show)); + return show; + }); + break; + case 'remove-sorting': { + setSort(null); + break; + } + case 'toggle-cleared': + setHideCleared(hide => { + hide = !hide; + setHideClearedPref(String(hide)); + return hide; + }); + break; + case 'toggle-reconciled': + setHideReconciled(hide => { + hide = !hide; + setHideReconciledPref(String(hide)); + return hide; + }); + break; + default: + } + }, + [ + accountId, + accounts, + dispatch, + location.state?.filterName, + onExport, + setHideClearedPref, + setHideReconciledPref, + setShowBalancesPref, + ], + ); - this.props.updateNewTransactions(updatedTransaction.id); - }; + const isNew = useCallback( + (id: string) => { + return newTransactions.includes(id); + }, + [newTransactions], + ); - canCalculateBalance = () => { - const accountId = this.props.accountId; - const account = this.props.accounts.find( - account => account.id === accountId, - ); - return ( - account && - this.state.search === '' && - this.state.filterConditions.length === 0 && - (this.state.sort === null || - (this.state.sort.field === 'date' && - this.state.sort.ascDesc === 'desc')) - ); - }; + const isMatched = useCallback( + (id: string) => { + return matchedTransactions.includes(id); + }, + [matchedTransactions], + ); - async calculateBalances() { - if (!this.canCalculateBalance()) { + const onCreatePayee = useCallback( + (name: string) => { + const trimmed = name.trim(); + if (trimmed !== '') { + return dispatch(createPayee(name)); + } return null; - } + }, + [dispatch], + ); - const { data } = await runQuery( - this.paged?.query - .options({ splits: 'none' }) - .select([{ balance: { $sumOver: '$amount' } }]), - ); + const lockTransactions = useCallback(async () => { + setIsProcessingTransactions(true); - return groupById<{ id: string; balance: number }>(data); - } + // const { data } = await runQuery( + // q('transactions') + // .filter({ cleared: true, reconciled: false, account: accountId }) + // .select('*') + // .options({ splits: 'grouped' }), + // ); + // let transactions = ungroupTransactions(data); - onAddTransaction = () => { - this.setState({ isAdding: true }); - }; + let transactionToLock = transactions.filter( + t => t.cleared && !t.reconciled && t.account === accountId, + ); - onExposeName = (flag: boolean) => { - this.setState({ editingName: flag }); - }; + const changes: { updated: Array> } = { + updated: [], + }; - onSaveName = (name: string) => { - const accountNameError = validateAccountName( - name, - this.props.accountId, - this.props.accounts, - ); - if (accountNameError) { - this.setState({ nameError: accountNameError }); - } else { - const account = this.props.accounts.find( - account => account.id === this.props.accountId, - ); - this.props.updateAccount({ ...account, name }); - this.setState({ editingName: false, nameError: '' }); - } - }; - - onToggleExtraBalances = () => { - this.props.setShowExtraBalances(!this.props.showExtraBalances); - }; - - onMenuSelect = async ( - item: - | 'link' - | 'unlink' - | 'close' - | 'reopen' - | 'export' - | 'toggle-balance' - | 'remove-sorting' - | 'toggle-cleared' - | 'toggle-reconciled', - ) => { - const accountId = this.props.accountId!; - const account = this.props.accounts.find( - account => account.id === accountId, - )!; - - switch (item) { - case 'link': - this.props.pushModal('add-account', { - upgradingAccountId: accountId, - }); - break; - case 'unlink': - this.props.pushModal('confirm-unlink-account', { - accountName: account.name, - onUnlink: () => { - this.props.unlinkAccount(accountId); - }, - }); - break; - case 'close': - this.props.openAccountCloseModal(accountId); - break; - case 'reopen': - this.props.reopenAccount(accountId); - break; - case 'export': - const accountName = this.getAccountTitle(account, accountId); - this.onExport(accountName); - break; - case 'toggle-balance': - if (this.state.showBalances) { - this.props.setShowBalances(false); - this.setState({ showBalances: false, balances: null }); - } else { - this.props.setShowBalances(true); - this.setState( - { - transactions: [], - transactionCount: 0, - filterConditions: [], - search: '', - sort: null, - showBalances: true, - }, - () => { - this.fetchTransactions(); - }, - ); - } - break; - case 'remove-sorting': { - this.setState({ sort: null }, () => { - const filterConditions = this.state.filterConditions; - if (filterConditions.length > 0) { - this.applyFilters([...filterConditions]); - } else { - this.fetchTransactions(); - } - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }); - break; - } - case 'toggle-cleared': - if (this.state.showCleared) { - this.props.setShowCleared(false); - this.setState({ showCleared: false }); - } else { - this.props.setShowCleared(true); - this.setState({ showCleared: true }); - } - break; - case 'toggle-reconciled': - if (this.state.showReconciled) { - this.props.setShowReconciled(false); - this.setState({ showReconciled: false }, () => - this.fetchTransactions(this.state.filterConditions), - ); - } else { - this.props.setShowReconciled(true); - this.setState({ showReconciled: true }, () => - this.fetchTransactions(this.state.filterConditions), - ); - } - break; - default: - } - }; - - getAccountTitle(account?: AccountEntity, id?: string) { - const { filterName } = this.props.location.state || {}; - - if (filterName) { - return filterName; - } - - if (!account) { - if (id === 'budgeted') { - return t('Budgeted Accounts'); - } else if (id === 'offbudget') { - return t('Off Budget Accounts'); - } else if (id === 'uncategorized') { - return t('Uncategorized'); - } else if (!id) { - return t('All Accounts'); - } - return null; - } - - return account.name; - } - - getBalanceQuery(id?: string) { - return { - name: `balance-query-${id}`, - query: this.makeRootTransactionsQuery().calculate({ $sum: '$amount' }), - } as const; - } - - getFilteredAmount = async () => { - const { data: amount } = await runQuery( - this.paged?.query.calculate({ $sum: '$amount' }), - ); - return amount; - }; - - isNew = (id: string) => { - return this.props.newTransactions.includes(id); - }; - - isMatched = (id: string) => { - return this.props.matchedTransactions.includes(id); - }; - - onCreatePayee = (name: string) => { - const trimmed = name.trim(); - if (trimmed !== '') { - return this.props.createPayee(name); - } - return null; - }; - - lockTransactions = async () => { - this.setState({ workingHard: true }); - - const { accountId } = this.props; - - const { data } = await runQuery( - q('transactions') - .filter({ cleared: true, reconciled: false, account: accountId }) - .select('*') - .options({ splits: 'grouped' }), - ); - let transactions = ungroupTransactions(data); - - const changes: { updated: Array> } = { - updated: [], - }; - - transactions.forEach(trans => { - const { diff } = updateTransaction(transactions, { + transactionToLock.forEach(trans => { + const { diff } = updateTransaction(transactionToLock, { ...trans, reconciled: true, }); - transactions = applyChanges(diff, transactions); + transactionToLock = applyChanges(diff, transactionToLock); changes.updated = changes.updated ? changes.updated.concat(diff.updated) @@ -911,32 +950,34 @@ class AccountInternal extends PureComponent< }); await send('transactions-batch-update', changes); - await this.refetchTransactions(); - }; - - onReconcile = async (balance: number) => { - this.setState(({ showCleared }) => ({ - reconcileAmount: balance, - showCleared: true, - prevShowCleared: showCleared, - })); - }; - - onDoneReconciling = async () => { - const { accountId } = this.props; - const { reconcileAmount } = this.state; - - const { data } = await runQuery( - q('transactions') - .filter({ cleared: true, account: accountId }) - .select('*') - .options({ splits: 'grouped' }), + reloadTransactions?.(); + setIsProcessingTransactions(false); + }, [accountId, reloadTransactions, transactions]); + + const onReconcile = useCallback( + async (balance: number) => { + setReconcileAmount(balance); + setHideCleared(false); + }, + [setHideCleared], + ); + + const onDoneReconciling = useCallback(async () => { + // const { data } = await runQuery( + // q('transactions') + // .filter({ cleared: true, account: accountId }) + // .select('*') + // .options({ splits: 'grouped' }), + // ); + // const transactions = ungroupTransactions(data); + + const clearedTransactions = transactions.filter( + t => t.cleared && t.account === accountId, ); - const transactions = ungroupTransactions(data); let cleared = 0; - transactions.forEach(trans => { + clearedTransactions.forEach(trans => { if (!trans.is_parent) { cleared += trans.amount; } @@ -945,148 +986,185 @@ class AccountInternal extends PureComponent< const targetDiff = (reconcileAmount || 0) - cleared; if (targetDiff === 0) { - await this.lockTransactions(); + await lockTransactions(); } - this.setState({ - reconcileAmount: null, - showCleared: this.state.prevShowCleared, - }); - }; - - onCreateReconciliationTransaction = async (diff: number) => { - // Create a new reconciliation transaction - const reconciliationTransactions = realizeTempTransactions([ - { - id: 'temp', - account: this.props.accountId!, - cleared: true, - reconciled: false, - amount: diff, - date: currentDay(), - notes: t('Reconciliation balance adjustment'), - }, - ]); + setReconcileAmount(null); + // Get back to previous state + setHideCleared(previousHideCleared); + }, [ + accountId, + lockTransactions, + previousHideCleared, + reconcileAmount, + transactions, + ]); + + const onCreateReconciliationTransaction = useCallback( + async (diff: number) => { + // Create a new reconciliation transaction + const reconciliationTransactions = realizeTempTransactions([ + { + id: 'temp', + account: accountId, + cleared: true, + reconciled: false, + amount: diff, + date: currentDay(), + notes: t('Reconciliation balance adjustment'), + }, + ]); - // Optimistic UI: update the transaction list before sending the data to the database - this.setState({ - transactions: [...reconciliationTransactions, ...this.state.transactions], - }); + // run rules on the reconciliation transaction + const ruledTransactions = await Promise.all( + reconciliationTransactions.map(transaction => + send('rules-run', { transaction }), + ), + ); - // run rules on the reconciliation transaction - const ruledTransactions = await Promise.all( - reconciliationTransactions.map(transaction => - send('rules-run', { transaction }), - ), - ); + // sync the reconciliation transaction + await send('transactions-batch-update', { + added: ruledTransactions, + }); + reloadTransactions?.(); + }, + [accountId, reloadTransactions], + ); - // sync the reconciliation transaction - await send('transactions-batch-update', { - added: ruledTransactions, - }); - await this.refetchTransactions(); - }; + const onBatchEditAndReload = useCallback( + (name: keyof TransactionEntity, ids: string[]) => { + onBatchEdit({ + name, + ids, + onSuccess: updatedIds => { + reloadTransactions?.(); - onShowTransactions = async (ids: string[]) => { - this.onApplyFilter({ - customName: t('Selected transactions'), - queryFilter: { id: { $oneof: ids } }, - }); - }; + if (tableRef.current) { + tableRef.current.edit(updatedIds[0], 'select', false); + } + }, + }); + }, + [onBatchEdit, reloadTransactions], + ); - onBatchEdit = (name: keyof TransactionEntity, ids: string[]) => { - this.props.onBatchEdit({ - name, - ids, - onSuccess: updatedIds => { - this.refetchTransactions(); + const onBatchDuplicateAndReload = useCallback( + (ids: string[]) => { + onBatchDuplicate({ ids, onSuccess: reloadTransactions }); + }, + [onBatchDuplicate, reloadTransactions], + ); - if (this.table.current) { - this.table.current.edit(updatedIds[0], 'select', false); - } - }, - }); - }; + const onBatchDeleteAndReload = useCallback( + (ids: string[]) => { + onBatchDelete({ ids, onSuccess: reloadTransactions }); + }, + [onBatchDelete, reloadTransactions], + ); - onBatchDuplicate = (ids: string[]) => { - this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions }); - }; + const onMakeAsSplitTransaction = useCallback( + async (ids: string[]) => { + setIsProcessingTransactions(true); - onBatchDelete = (ids: string[]) => { - this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions }); - }; + // const { data } = await runQuery( + // q('transactions') + // .filter({ id: { $oneof: ids } }) + // .select('*') + // .options({ splits: 'none' }), + // ); - onMakeAsSplitTransaction = async (ids: string[]) => { - this.setState({ workingHard: true }); + // const transactions: TransactionEntity[] = data; - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'none' }), - ); + const noneSplitTransactions = transactions.filter( + t => !t.is_parent && !t.parent_id && ids.includes(t.id), + ); + if (!noneSplitTransactions || noneSplitTransactions.length === 0) { + return; + } - const transactions: TransactionEntity[] = data; + const [firstTransaction] = noneSplitTransactions; + const parentTransaction = { + id: uuidv4(), + is_parent: true, + cleared: noneSplitTransactions.every(t => !!t.cleared), + date: firstTransaction.date, + account: firstTransaction.account, + amount: noneSplitTransactions + .map(t => t.amount) + .reduce((total, amount) => total + amount, 0), + }; + const childTransactions = noneSplitTransactions.map(t => + makeChild(parentTransaction, t), + ); - if (!transactions || transactions.length === 0) { - return; - } + await send('transactions-batch-update', { + added: [parentTransaction], + updated: childTransactions, + }); - const [firstTransaction] = transactions; - const parentTransaction = { - id: uuidv4(), - is_parent: true, - cleared: transactions.every(t => !!t.cleared), - date: firstTransaction.date, - account: firstTransaction.account, - amount: transactions - .map(t => t.amount) - .reduce((total, amount) => total + amount, 0), - }; - const childTransactions = transactions.map(t => - makeChild(parentTransaction, t), - ); + reloadTransactions?.(); + setIsProcessingTransactions(false); + }, + [reloadTransactions, transactions], + ); - await send('transactions-batch-update', { - added: [parentTransaction], - updated: childTransactions, - }); + const onMakeAsNonSplitTransactions = useCallback( + async (ids: string[]) => { + setIsProcessingTransactions(true); + + // const { data } = await runQuery( + // q('transactions') + // .filter({ id: { $oneof: ids } }) + // .select('*') + // .options({ splits: 'grouped' }), + // ); + + // const groupedTransactions: TransactionEntity[] = data; + + let changes: { + updated: TransactionEntity[]; + deleted: TransactionEntity[]; + } = { + updated: [], + deleted: [], + }; - this.refetchTransactions(); - }; + const groupedTransactionsToUpdate = transactionsGrouped.filter( + t => t.is_parent, + ); - onMakeAsNonSplitTransactions = async (ids: string[]) => { - this.setState({ workingHard: true }); + for (const groupedTransaction of groupedTransactionsToUpdate) { + const transactions = ungroupTransaction(groupedTransaction); + const [parentTransaction, ...childTransactions] = transactions; - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'grouped' }), - ); + if (ids.includes(parentTransaction.id)) { + // Unsplit all child transactions. + const diff = makeAsNonChildTransactions( + childTransactions, + transactions, + ); - const groupedTransactions: TransactionEntity[] = data; + changes = { + updated: [...changes.updated, ...diff.updated], + deleted: [...changes.deleted, ...diff.deleted], + }; - let changes: { - updated: TransactionEntity[]; - deleted: TransactionEntity[]; - } = { - updated: [], - deleted: [], - }; + // Already processed the child transactions above, no need to process them below. + continue; + } - const groupedTransactionsToUpdate = groupedTransactions.filter( - t => t.is_parent, - ); + // Unsplit selected child transactions. - for (const groupedTransaction of groupedTransactionsToUpdate) { - const transactions = ungroupTransaction(groupedTransaction); - const [parentTransaction, ...childTransactions] = transactions; + const selectedChildTransactions = childTransactions.filter(t => + ids.includes(t.id), + ); + + if (selectedChildTransactions.length === 0) { + continue; + } - if (ids.includes(parentTransaction.id)) { - // Unsplit all child transactions. const diff = makeAsNonChildTransactions( - childTransactions, + selectedChildTransactions, transactions, ); @@ -1094,789 +1172,549 @@ class AccountInternal extends PureComponent< updated: [...changes.updated, ...diff.updated], deleted: [...changes.deleted, ...diff.deleted], }; - - // Already processed the child transactions above, no need to process them below. - continue; } - // Unsplit selected child transactions. + await send('transactions-batch-update', changes); - const selectedChildTransactions = childTransactions.filter(t => - ids.includes(t.id), - ); - - if (selectedChildTransactions.length === 0) { - continue; - } + reloadTransactions?.(); - const diff = makeAsNonChildTransactions( - selectedChildTransactions, - transactions, - ); - - changes = { - updated: [...changes.updated, ...diff.updated], - deleted: [...changes.deleted, ...diff.deleted], - }; - } + const transactionsToSelect = changes.updated.map(t => t.id); + dispatchSelected?.({ + type: 'select-all', + ids: transactionsToSelect, + }); - await send('transactions-batch-update', changes); + setIsProcessingTransactions(false); + }, + [dispatchSelected, reloadTransactions, transactionsGrouped], + ); - this.refetchTransactions(); + const checkForReconciledTransactions = useCallback( + async ( + ids: string[], + confirmReason: string, + onConfirm: (ids: string[]) => void, + ) => { + // const { data } = await runQuery( + // q('transactions') + // .filter({ id: { $oneof: ids }, reconciled: true }) + // .select('*') + // .options({ splits: 'grouped' }), + // ); + // const transactions = ungroupTransactions(data); + + const reconciledTransactions = transactions.filter( + t => t.reconciled && ids.includes(t.id), + ); + if (reconciledTransactions.length > 0) { + dispatch( + pushModal('confirm-transaction-edit', { + onConfirm: () => { + onConfirm(ids); + }, + confirmReason, + }), + ); + } else { + onConfirm(ids); + } + }, + [dispatch, transactions], + ); - const transactionsToSelect = changes.updated.map(t => t.id); - this.dispatchSelected?.({ - type: 'select-all', - ids: transactionsToSelect, - }); - }; - - checkForReconciledTransactions = async ( - ids: string[], - confirmReason: string, - onConfirm: (ids: string[]) => void, - ) => { - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids }, reconciled: true }) - .select('*') - .options({ splits: 'grouped' }), - ); - const transactions = ungroupTransactions(data); - if (transactions.length > 0) { - this.props.pushModal('confirm-transaction-edit', { - onConfirm: () => { - onConfirm(ids); - }, - confirmReason, + const onBatchLinkScheduleAndReload = useCallback( + (ids: string[]) => { + onBatchLinkSchedule({ + ids, + account: accounts.find(a => a.id === accountId), + onSuccess: reloadTransactions, }); - } else { - onConfirm(ids); - } - }; - - onBatchLinkSchedule = (ids: string[]) => { - this.props.onBatchLinkSchedule({ - ids, - account: this.props.accounts.find(a => a.id === this.props.accountId), - onSuccess: this.refetchTransactions, - }); - }; - - onBatchUnlinkSchedule = (ids: string[]) => { - this.props.onBatchUnlinkSchedule({ - ids, - onSuccess: this.refetchTransactions, - }); - }; - - onCreateRule = async (ids: string[]) => { - const { data } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*') - .options({ splits: 'grouped' }), - ); + }, + [accountId, accounts, onBatchLinkSchedule, reloadTransactions], + ); - const transactions = ungroupTransactions(data); - const ruleTransaction = transactions[0]; - const childTransactions = transactions.filter( - t => t.parent_id === ruleTransaction.id, - ); + const onBatchUnlinkScheduleAndReload = useCallback( + (ids: string[]) => { + onBatchUnlinkSchedule({ + ids, + onSuccess: reloadTransactions, + }); + }, + [onBatchUnlinkSchedule, reloadTransactions], + ); - const payeeCondition = ruleTransaction.imported_payee - ? ({ - field: 'imported_payee', - op: 'is', - value: ruleTransaction.imported_payee, - type: 'string', - } satisfies RuleConditionEntity) - : ({ - field: 'payee', - op: 'is', - value: ruleTransaction.payee!, - type: 'id', - } satisfies RuleConditionEntity); - const amountCondition = { - field: 'amount', - op: 'isapprox', - value: ruleTransaction.amount, - type: 'number', - } satisfies RuleConditionEntity; - - const rule = { - stage: null, - conditionsOp: 'and', - conditions: [payeeCondition, amountCondition], - actions: [ - ...(childTransactions.length === 0 - ? [ - { - op: 'set', - field: 'category', - value: ruleTransaction.category, - type: 'id', - options: { - splitIndex: 0, - }, - } satisfies RuleActionEntity, - ] - : []), - ...childTransactions.flatMap((sub, index) => [ - { - op: 'set-split-amount', - value: sub.amount, - options: { - splitIndex: index + 1, - method: 'fixed-amount', - }, - } satisfies RuleActionEntity, - { - op: 'set', - field: 'category', - value: sub.category, - type: 'id', - options: { - splitIndex: index + 1, - }, - } satisfies RuleActionEntity, - ]), - ], - } satisfies NewRuleEntity; - - this.props.pushModal('edit-rule', { rule }); - }; - - onSetTransfer = async (ids: string[]) => { - const onConfirmTransfer = async (ids: string[]) => { - this.setState({ workingHard: true }); - - const payees = await this.props.getPayees(); - const { data: transactions } = await runQuery( - q('transactions') - .filter({ id: { $oneof: ids } }) - .select('*'), + const onCreateRule = useCallback( + async (ids: string[]) => { + // const { data } = await runQuery( + // q('transactions') + // .filter({ id: { $oneof: ids } }) + // .select('*') + // .options({ splits: 'grouped' }), + // ); + + // const transactions = ungroupTransactions(data); + + const selectedTransactions = transactions.filter(t => ids.includes(t.id)); + const [ruleTransaction, ...otherTransactions] = selectedTransactions; + const childTransactions = otherTransactions.filter( + t => t.parent_id === ruleTransaction.id, ); - const [fromTrans, toTrans] = transactions; - if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) { - const fromPayee = payees.find( - p => p.transfer_acct === fromTrans.account, - ); - const toPayee = payees.find(p => p.transfer_acct === toTrans.account); - - const changes = { - updated: [ + const payeeCondition = ruleTransaction.imported_payee + ? ({ + field: 'imported_payee', + op: 'is', + value: ruleTransaction.imported_payee, + type: 'string', + } satisfies RuleConditionEntity) + : ({ + field: 'payee', + op: 'is', + value: ruleTransaction.payee!, + type: 'id', + } satisfies RuleConditionEntity); + const amountCondition = { + field: 'amount', + op: 'isapprox', + value: ruleTransaction.amount, + type: 'number', + } satisfies RuleConditionEntity; + + const rule = { + stage: null, + conditionsOp: 'and', + conditions: [payeeCondition, amountCondition], + actions: [ + ...(childTransactions.length === 0 + ? [ + { + op: 'set', + field: 'category', + value: ruleTransaction.category, + type: 'id', + options: { + splitIndex: 0, + }, + } satisfies RuleActionEntity, + ] + : []), + ...childTransactions.flatMap((sub, index) => [ { - ...fromTrans, - payee: toPayee?.id, - transfer_id: toTrans.id, - }, + op: 'set-split-amount', + value: sub.amount, + options: { + splitIndex: index + 1, + method: 'fixed-amount', + }, + } satisfies RuleActionEntity, { - ...toTrans, - payee: fromPayee?.id, - transfer_id: fromTrans.id, - }, - ], - }; + op: 'set', + field: 'category', + value: sub.category, + type: 'id', + options: { + splitIndex: index + 1, + }, + } satisfies RuleActionEntity, + ]), + ], + } satisfies NewRuleEntity; - await send('transactions-batch-update', changes); - } + dispatch(pushModal('edit-rule', { rule })); + }, + [dispatch, transactions], + ); - await this.refetchTransactions(); - }; + const onSetTransfer = useCallback( + async (ids: string[]) => { + const onConfirmTransfer = async (ids: string[]) => { + setIsProcessingTransactions(true); - await this.checkForReconciledTransactions( - ids, - 'batchEditWithReconciled', - onConfirmTransfer, - ); - }; - - onConditionsOpChange = (value: 'and' | 'or') => { - this.setState({ filterConditionsOp: value }); - this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); - this.applyFilters([...this.state.filterConditions]); - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; + // const { data: transactions } = await runQuery( + // q('transactions') + // .filter({ id: { $oneof: ids } }) + // .select('*'), + // ); + // const [fromTrans, toTrans] = transactions; - onReloadSavedFilter = (savedFilter: SavedFilter, item: string) => { - if (item === 'reload') { - const [savedFilter] = this.props.savedFilters.filter( - f => f.id === this.state.filterId?.id, - ); - this.setState({ filterConditionsOp: savedFilter.conditionsOp ?? 'and' }); - this.applyFilters([...savedFilter.conditions]); - } else { - if (savedFilter.status) { - this.setState({ - filterConditionsOp: savedFilter.conditionsOp ?? 'and', - }); - this.applyFilters([...savedFilter.conditions]); - } - } - this.setState({ filterId: { ...this.state.filterId, ...savedFilter } }); - }; - - onClearFilters = () => { - this.setState({ filterConditionsOp: 'and' }); - this.setState({ filterId: undefined }); - this.applyFilters([]); - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; - - onUpdateFilter = ( - oldCondition: RuleConditionEntity, - updatedCondition: RuleConditionEntity, - ) => { - this.applyFilters( - this.state.filterConditions.map(c => - c === oldCondition ? updatedCondition : c, - ), - ); - this.setState({ - filterId: { - ...this.state.filterId, - status: this.state.filterId && 'changed', - }, - }); - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; - - onDeleteFilter = (condition: RuleConditionEntity) => { - this.applyFilters(this.state.filterConditions.filter(c => c !== condition)); - if (this.state.filterConditions.length === 1) { - this.setState({ filterId: undefined }); - this.setState({ filterConditionsOp: 'and' }); - } else { - this.setState({ - filterId: { - ...this.state.filterId, - status: this.state.filterId && 'changed', - }, - }); - } - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; - - onApplyFilter = async (conditionOrSavedFilter: ConditionEntity) => { - let filterConditions = this.state.filterConditions; - - if ( - 'customName' in conditionOrSavedFilter && - conditionOrSavedFilter.customName - ) { - filterConditions = filterConditions.filter( - c => - !isTransactionFilterEntity(c) && - c.customName !== conditionOrSavedFilter.customName, - ); - } - - if (isTransactionFilterEntity(conditionOrSavedFilter)) { - // A saved filter was passed in. - const savedFilter = conditionOrSavedFilter; - this.setState({ - filterId: { ...savedFilter, status: 'saved' }, - }); - this.setState({ filterConditionsOp: savedFilter.conditionsOp }); - this.applyFilters([...savedFilter.conditions]); - } else { - // A condition was passed in. - const condition = conditionOrSavedFilter; - this.setState({ - filterId: { - ...this.state.filterId, - status: this.state.filterId && 'changed', - }, - }); - this.applyFilters([...filterConditions, condition]); - } + const selectedTransactions = transactions.filter(t => + ids.includes(t.id), + ); + const [fromTrans, toTrans] = selectedTransactions; + + if ( + selectedTransactions.length === 2 && + validForTransfer(fromTrans, toTrans) + ) { + const fromPayee = payees.find( + p => p.transfer_acct === fromTrans.account, + ); + const toPayee = payees.find(p => p.transfer_acct === toTrans.account); - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; - - onScheduleAction = async ( - name: 'skip' | 'post-transaction', - ids: string[], - ) => { - switch (name) { - case 'post-transaction': - for (const id of ids) { - const parts = id.split('/'); - await send('schedule/post-transaction', { id: parts[1] }); - } - this.refetchTransactions(); - break; - case 'skip': - for (const id of ids) { - const parts = id.split('/'); - await send('schedule/skip-next-date', { id: parts[1] }); + const changes = { + updated: [ + { + ...fromTrans, + payee: toPayee?.id, + transfer_id: toTrans.id, + }, + { + ...toTrans, + payee: fromPayee?.id, + transfer_id: fromTrans.id, + }, + ], + }; + + await send('transactions-batch-update', changes); } - break; - default: - } - }; - - applyFilters = async (conditions: ConditionEntity[]) => { - if (conditions.length > 0) { - const filteredCustomQueryFilters: Partial[] = - conditions.filter(cond => !isTransactionFilterEntity(cond)); - const customQueryFilters = filteredCustomQueryFilters.map( - f => f.queryFilter, - ); - const { filters: queryFilters } = await send( - 'make-filters-from-conditions', - { - conditions: conditions.filter( - cond => isTransactionFilterEntity(cond) || !cond.customName, - ), - }, - ); - const conditionsOpKey = - this.state.filterConditionsOp === 'or' ? '$or' : '$and'; - this.currentQuery = this.rootQuery.filter({ - [conditionsOpKey]: [...queryFilters, ...customQueryFilters], - }); - this.setState( - { - filterConditions: conditions, - }, - () => { - this.updateQuery(this.currentQuery, true); - }, - ); - } else { - this.setState( - { - transactions: [], - transactionCount: 0, - filterConditions: conditions, - }, - () => { - this.fetchTransactions(); - }, + reloadTransactions?.(); + setIsProcessingTransactions(false); + }; + + await checkForReconciledTransactions( + ids, + 'batchEditWithReconciled', + onConfirmTransfer, ); - } + }, + [checkForReconciledTransactions, payees, reloadTransactions, transactions], + ); - if (this.state.sort !== null) { - this.applySort(); - } - }; - - applySort = ( - field?: string, - ascDesc?: 'asc' | 'desc', - prevField?: string, - prevAscDesc?: 'asc' | 'desc', - ) => { - const filterConditions = this.state.filterConditions; - const isFiltered = filterConditions.length > 0; - const sortField = getField(!field ? this.state.sort?.field : field); - const sortAscDesc = !ascDesc ? this.state.sort?.ascDesc : ascDesc; - const sortPrevField = getField( - !prevField ? this.state.sort?.prevField : prevField, - ); - const sortPrevAscDesc = !prevField - ? this.state.sort?.prevAscDesc - : prevAscDesc; - - const sortCurrentQuery = function ( - that: AccountInternal, - sortField: string, - sortAscDesc?: 'asc' | 'desc', - ) { - if (sortField === 'cleared') { - that.currentQuery = that.currentQuery.orderBy({ - reconciled: sortAscDesc, - }); + const onConditionsOpChange = useCallback((value: 'and' | 'or') => { + setFilterConditionsOp(value); + setFilterId(f => ({ ...f, status: 'changed' })); + }, []); + + const onReloadSavedFilter = useCallback( + (savedFilter: SavedFilter, item: string) => { + if (item === 'reload') { + const [savedFilter] = savedFilters.filter(f => f.id === filterId?.id); + setFilterConditionsOp(savedFilter.conditionsOp ?? 'and'); + setFilterConditions([...savedFilter.conditions]); + } else { + if (savedFilter.status) { + setFilterConditionsOp(savedFilter.conditionsOp ?? 'and'); + setFilterConditions([...savedFilter.conditions]); + } } + setFilterId(f => ({ ...f, ...savedFilter })); + }, + [filterId?.id, savedFilters], + ); - that.currentQuery = that.currentQuery.orderBy({ - [sortField]: sortAscDesc, - }); - }; + const onClearFilters = useCallback(() => { + setFilterConditionsOp('and'); + setFilterId(undefined); + setFilterConditions([]); + }, []); + + const onUpdateFilter = useCallback( + ( + oldCondition: RuleConditionEntity, + updatedCondition: RuleConditionEntity, + ) => { + // TODO: verify if setting the conditions correctly apply the filters to the query. + setFilterConditions(f => + f.map(c => (c === oldCondition ? updatedCondition : c)), + ); + setFilterId(f => ({ ...f, status: f && 'changed' })); + }, + [], + ); - const sortRootQuery = function ( - that: AccountInternal, - sortField: string, - sortAscDesc?: 'asc' | 'desc', - ) { - if (sortField === 'cleared') { - that.currentQuery = that.rootQuery.orderBy({ - reconciled: sortAscDesc, - }); - that.currentQuery = that.currentQuery.orderBy({ - cleared: sortAscDesc, - }); + const onDeleteFilter = useCallback( + (condition: RuleConditionEntity) => { + setFilterConditions(f => f.filter(c => c !== condition)); + + if (filterConditions.length === 1) { + setFilterId(undefined); + setFilterConditionsOp('and'); } else { - that.currentQuery = that.rootQuery.orderBy({ - [sortField]: sortAscDesc, - }); + setFilterId(f => ({ ...f, status: f && 'changed' })); } - }; + }, + [filterConditions.length], + ); - // sort by previously used sort field, if any - const maybeSortByPreviousField = function ( - that: AccountInternal, - sortPrevField: string, - sortPrevAscDesc?: 'asc' | 'desc', - ) { - if (!sortPrevField) { - return; + const onApplyFilter = useCallback( + async (conditionOrSavedFilter: ConditionEntity) => { + let _filterConditions = filterConditions; + + if ( + 'customName' in conditionOrSavedFilter && + conditionOrSavedFilter.customName + ) { + _filterConditions = filterConditions.filter( + c => + !isTransactionFilterEntity(c) && + c.customName !== conditionOrSavedFilter.customName, + ); } - if (sortPrevField === 'cleared') { - that.currentQuery = that.currentQuery.orderBy({ - reconciled: sortPrevAscDesc, - }); + if (isTransactionFilterEntity(conditionOrSavedFilter)) { + // A saved filter was passed in. + const savedFilter = conditionOrSavedFilter; + setFilterId({ ...savedFilter, status: 'saved' }); + setFilterConditionsOp(savedFilter.conditionsOp); + setFilterConditions([...savedFilter.conditions]); + } else { + // A condition was passed in. + const condition = conditionOrSavedFilter; + setFilterId(f => ({ ...f, status: f && 'changed' })); + setFilterConditions(f => [...f, condition]); } + }, + [filterConditions], + ); - that.currentQuery = that.currentQuery.orderBy({ - [sortPrevField]: sortPrevAscDesc, + const onShowTransactions = useCallback( + async (ids: string[]) => { + onApplyFilter({ + customName: t('Selected transactions'), + queryFilter: { id: { $oneof: ids } }, }); - }; + }, + [onApplyFilter], + ); - switch (true) { - // called by applyFilters to sort an already filtered result - case !field: - sortCurrentQuery(this, sortField, sortAscDesc); - break; - - // called directly from UI by sorting a column. - // active filters need to be applied before sorting - case isFiltered: - this.applyFilters([...filterConditions]); - sortCurrentQuery(this, sortField, sortAscDesc); - break; - - // called directly from UI by sorting a column. - // no active filters, start a new root query. - case !isFiltered: - sortRootQuery(this, sortField, sortAscDesc); - break; - - default: - } + const onScheduleAction = useCallback( + async (name: 'skip' | 'post-transaction', ids: string[]) => { + switch (name) { + case 'post-transaction': + for (const id of ids) { + const parts = id.split('/'); + await send('schedule/post-transaction', { id: parts[1] }); + } + reloadTransactions?.(); + break; + case 'skip': + for (const id of ids) { + const parts = id.split('/'); + await send('schedule/skip-next-date', { id: parts[1] }); + } + break; + default: + throw new Error(`Unknown action: ${name}`); + } + }, + [reloadTransactions], + ); - maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc); - this.updateQuery(this.currentQuery, isFiltered); - }; - - onSort = (headerClicked: string, ascDesc: 'asc' | 'desc') => { - let prevField: string | undefined; - let prevAscDesc: 'asc' | 'desc' | undefined; - //if staying on same column but switching asc/desc - //then keep prev the same - if (headerClicked === this.state.sort?.field) { - prevField = this.state.sort.prevField; - prevAscDesc = this.state.sort.prevAscDesc; - this.setState({ - sort: { - ...this.state.sort, - ascDesc, - }, - }); - } else { - //if switching to new column then capture state - //of current sort column as prev - prevField = this.state.sort?.field; - prevAscDesc = this.state.sort?.ascDesc; - this.setState({ - sort: { + const onSort = useCallback( + (headerClicked: string, ascDesc: 'asc' | 'desc') => { + //if staying on same column but switching asc/desc + //then keep prev the same + if (headerClicked === sort?.field) { + setSort(s => ({ ...s, ascDesc })); + } else { + setSort(s => ({ + ...s, field: headerClicked, ascDesc, - prevField: this.state.sort?.field, - prevAscDesc: this.state.sort?.ascDesc, - }, - }); - } + prevField: s?.field, + prevAscDesc: s?.ascDesc, + })); + } + }, + [sort?.field], + ); - this.applySort(headerClicked, ascDesc, prevField, prevAscDesc); - if (this.state.search !== '') { - this.onSearch(this.state.search); - } - }; + const account = accounts.find(account => account.id === accountId); + const accountName = getAccountTitle( + account, + accountId, + location.state?.filterName, + ); - render() { - const { - accounts, - categoryGroups, - payees, - dateFormat, - hideFraction, - addNotification, - accountsSyncing, - failedAccounts, - replaceModal, - showExtraBalances, - accountId, - categoryId, - } = this.props; - const { - transactions, - loading, - workingHard, - filterId, - reconcileAmount, - transactionsFiltered, - editingName, - showBalances, - balances, - showCleared, - showReconciled, - filteredAmount, - } = this.state; - - const account = accounts.find(account => account.id === accountId); - const accountName = this.getAccountTitle(account, accountId); - - if (!accountName && !loading) { - // This is probably an account that was deleted, so redirect to - // all accounts - return ; - } + const category = categoryGroups + .flatMap(g => g.categories) + .find(category => category?.id === categoryId); - const category = categoryGroups - .flatMap(g => g.categories) - .find(category => category?.id === categoryId); - - const showEmptyMessage = !loading && !accountId && accounts.length === 0; - - const isNameEditable = - accountId && - accountId !== 'budgeted' && - accountId !== 'offbudget' && - accountId !== 'uncategorized'; - - const balanceQuery = this.getBalanceQuery(accountId); - - return ( - - this.props.splitsExpandedDispatch({ type: 'close-splits', ids }) - } - > - {(allTransactions, allBalances) => ( - (this.dispatchSelected = dispatch)} - selectAllFilter={item => !item._unmatched && !item.is_parent} - > - - - - - - this.paged && this.paged.fetchNext() - } - accounts={accounts} - category={category} - categoryGroups={categoryGroups} - payees={payees} - balances={allBalances} - showBalances={!!allBalances} - showReconciled={showReconciled} - showCleared={showCleared} - showAccount={ - !accountId || - accountId === 'offbudget' || - accountId === 'budgeted' || - accountId === 'uncategorized' - } - isAdding={this.state.isAdding} - isNew={this.isNew} - isMatched={this.isMatched} - isFiltered={transactionsFiltered} - dateFormat={dateFormat} - hideFraction={hideFraction} - addNotification={addNotification} - renderEmpty={() => - showEmptyMessage ? ( - replaceModal('add-account')} /> - ) : !loading ? ( - - No transactions - - ) : null - } - onSort={this.onSort} - sortField={this.state.sort?.field} - ascDesc={this.state.sort?.ascDesc} - onChange={this.onTransactionsChange} - onRefetch={this.refetchTransactions} - onCloseAddTransaction={() => - this.setState({ isAdding: false }) - } - onCreatePayee={this.onCreatePayee} - onApplyFilter={this.onApplyFilter} - /> - - - - )} - - ); - } -} + const showEmptyMessage = + !isTransactionsLoading && !accountId && accounts.length === 0; -type AccountHackProps = Omit< - AccountInternalProps, - | 'splitsExpandedDispatch' - | 'onBatchEdit' - | 'onBatchDuplicate' - | 'onBatchLinkSchedule' - | 'onBatchUnlinkSchedule' - | 'onBatchDelete' ->; - -function AccountHack(props: AccountHackProps) { - const { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); - const { - onBatchEdit, - onBatchDuplicate, - onBatchLinkSchedule, - onBatchUnlinkSchedule, - onBatchDelete, - } = useTransactionBatchActions(); + const isNameEditable = + accountId && + accountId !== 'budgeted' && + accountId !== 'offbudget' && + accountId !== 'uncategorized'; + + const isFiltered = filterConditions.length > 0 || isSearching; + + const transactionsWithPreview = useMemo( + () => + !isFiltered + ? previewTransactionsWithInverse.concat(transactions) + : transactions, + [isFiltered, previewTransactionsWithInverse, transactions], + ); + + const filteredBalance = useMemo(() => { + return transactions.reduce((total, t) => total + t.amount, 0); + }, [transactions]); + + const selectedInst = useSelected( + 'transactions', + transactionsWithPreview, + [], + item => !item._unmatched && !item.is_parent, + ); + + // TODO: Can we just use IDs of the loaded transactions? + // const fetchAllIds = useCallback(async () => { + // const { data } = await runQuery(transactionsQuery.select('id')); + // // Remember, this is the `grouped` split type so we need to deal + // // with the `subtransactions` property + // return data.reduce((arr: string[], t: TransactionEntity) => { + // arr.push(t.id); + // t.subtransactions?.forEach(sub => arr.push(sub.id)); + // return arr; + // }, []); + // }, [transactionsQuery]); + + if (!accountName && !isTransactionsLoading) { + // This is probably an account that was deleted, so redirect to all accounts + return ; + } return ( - + transactions.map(t => t.id)} + > + + + + + 0} + dateFormat={dateFormat} + hideFraction={hideFraction} + renderEmpty={() => + showEmptyMessage ? ( + dispatch(replaceModal('add-account'))} + /> + ) : !isTransactionsLoading ? ( + + No transactions + + ) : null + } + addNotification={notification => + dispatch(addNotification(notification)) + } + onSort={onSort} + sortField={sort?.field} + ascDesc={sort?.ascDesc} + // onChange={onTransactionsChange} + onChange={() => {}} + onRefetch={reloadTransactions} + onCloseAddTransaction={() => setIsAdding(false)} + onCreatePayee={onCreatePayee} + onApplyFilter={onApplyFilter} + /> + + + ); } export function Account() { const params = useParams(); const location = useLocation(); - - const { grouped: categoryGroups } = useCategories(); - const newTransactions = useSelector(state => state.queries.newTransactions); - const matchedTransactions = useSelector( - state => state.queries.matchedTransactions, - ); - const accounts = useAccounts(); - const payees = usePayees(); - const failedAccounts = useFailedAccounts(); - const dateFormat = useDateFormat() || 'MM/dd/yyyy'; - const [hideFraction] = useSyncedPref('hideFraction'); const [expandSplits] = useLocalPref('expand-splits'); - const [showBalances, setShowBalances] = useSyncedPref( - `show-balances-${params.id}`, - ); - const [hideCleared, setHideCleared] = useSyncedPref( - `hide-cleared-${params.id}`, - ); - const [hideReconciled, setHideReconciled] = useSyncedPref( - `hide-reconciled-${params.id}`, - ); - const [showExtraBalances, setShowExtraBalances] = useSyncedPref( - `show-extra-balances-${params.id || 'all-accounts'}`, - ); - const modalShowing = useSelector(state => state.modals.modalStack.length > 0); - const accountsSyncing = useSelector(state => state.account.accountsSyncing); - const lastUndoState = useSelector(state => state.app.lastUndoState); - const filterConditions = location?.state?.filterConditions || []; - - const savedFiters = useFilters(); - const actionCreators = useActions(); const schedulesQueryBuilder = useMemo( () => defaultSchedulesQueryBuilder(params.id), @@ -1884,41 +1722,13 @@ export function Account() { ); return ( - + - - setShowBalances(String(showBalances)) - } - showCleared={String(hideCleared) !== 'true'} - setShowCleared={val => setHideCleared(String(!val))} - showReconciled={String(hideReconciled) !== 'true'} - setShowReconciled={val => setHideReconciled(String(!val))} - showExtraBalances={String(showExtraBalances) === 'true'} - setShowExtraBalances={extraBalances => - setShowExtraBalances(String(extraBalances)) - } - payees={payees} - modalShowing={modalShowing} - accountsSyncing={accountsSyncing} - lastUndoState={lastUndoState} - filterConditions={filterConditions} - categoryGroups={categoryGroups} - {...actionCreators} + diff --git a/packages/desktop-client/src/components/accounts/Balance.jsx b/packages/desktop-client/src/components/accounts/Balance.jsx index 741a1fa96aa..9f31eb709f2 100644 --- a/packages/desktop-client/src/components/accounts/Balance.jsx +++ b/packages/desktop-client/src/components/accounts/Balance.jsx @@ -1,7 +1,7 @@ -import React, { useRef } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useHover } from 'usehooks-ts'; +import { css } from '@emotion/css'; import { isPreviewId } from 'loot-core/shared/transactions'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; @@ -11,13 +11,14 @@ import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { useSelectedItems } from '../../hooks/useSelected'; import { SvgArrowButtonRight1 } from '../../icons/v2'; import { theme } from '../../style'; -import { Button } from '../common/Button2'; +import { ButtonWithLoading } from '../common/Button2'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { PrivacyFilter } from '../PrivacyFilter'; import { CellValue, CellValueText } from '../spreadsheet/CellValue'; import { useFormat } from '../spreadsheet/useFormat'; import { useSheetValue } from '../spreadsheet/useSheetValue'; +import { runQuery } from 'loot-core/client/query-helpers'; function DetailedBalance({ name, balance, isExactBalance = true }) { const format = useFormat(); @@ -42,7 +43,7 @@ function DetailedBalance({ name, balance, isExactBalance = true }) { ); } -function SelectedBalance({ selectedItems, account }) { +function SelectedBalance({ selectedItems, accountId }) { const { t } = useTranslation(); const name = `selected-balance-${[...selectedItems].join('-')}`; @@ -87,7 +88,7 @@ function SelectedBalance({ selectedItems, account }) { isExactBalance = false; } - if (!account || account.id === s._account) { + if (accountId !== s._account) { scheduleBalance += getScheduledAmount(s._amount); } else { scheduleBalance -= getScheduledAmount(s._amount); @@ -114,28 +115,27 @@ function SelectedBalance({ selectedItems, account }) { ); } -function FilteredBalance({ filteredAmount }) { +function FilteredBalance({ filteredBalance }) { const { t } = useTranslation(); - return ( ); } -function MoreBalances({ balanceQuery }) { +function MoreBalances({ accountId, balanceQuery }) { const { t } = useTranslation(); const cleared = useSheetValue({ - name: balanceQuery.name + '-cleared', - query: balanceQuery.query.filter({ cleared: true }), + name: `balance-query-${accountId}-cleared`, + query: balanceQuery?.filter({ cleared: true }), }); const uncleared = useSheetValue({ - name: balanceQuery.name + '-uncleared', - query: balanceQuery.query.filter({ cleared: false }), + name: `balance-query-${accountId}-uncleared`, + query: balanceQuery?.filter({ cleared: false }), }); return ( @@ -147,16 +147,23 @@ function MoreBalances({ balanceQuery }) { } export function Balances({ + accountId, balanceQuery, + filteredBalance, + showFilteredBalance, showExtraBalances, onToggleExtraBalances, - account, - isFiltered, - filteredAmount, }) { const selectedItems = useSelectedItems(); - const buttonRef = useRef(null); - const isButtonHovered = useHover(buttonRef); + // const balanceQuery = transactionsQuery?.calculate({ $sum: '$amount' }); + const balanceBinding = useMemo( + () => ({ + name: `balance-query-${accountId}`, + query: balanceQuery, + value: 0, + }), + [accountId, balanceQuery], + ); return ( - - {showExtraBalances && } - + )} {selectedItems.size > 0 && ( - + + )} + {showFilteredBalance && ( + )} - {isFiltered && } ); } diff --git a/packages/desktop-client/src/components/accounts/Header.tsx b/packages/desktop-client/src/components/accounts/Header.tsx index c45ae73eb1c..c594c3c8e9c 100644 --- a/packages/desktop-client/src/components/accounts/Header.tsx +++ b/packages/desktop-client/src/components/accounts/Header.tsx @@ -8,6 +8,7 @@ import React, { import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; +import { type Query } from 'loot-core/shared/query'; import { type AccountEntity, type RuleConditionEntity, @@ -18,7 +19,7 @@ import { import { useLocalPref } from '../../hooks/useLocalPref'; import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; import { useSyncServerStatus } from '../../hooks/useSyncServerStatus'; -import { AnimatedLoading } from '../../icons/AnimatedLoading'; +// import { AnimatedLoading } from '../../icons/AnimatedLoading'; import { SvgAdd } from '../../icons/v1'; import { SvgArrowsExpand3, @@ -52,7 +53,8 @@ type AccountHeaderProps = { tableRef: TableRef; editingName: boolean; isNameEditable: boolean; - workingHard: boolean; + isLoading: boolean; + accountId: AccountEntity['id'] | string; accountName: string; account: AccountEntity; filterId?: SavedFilter; @@ -60,17 +62,18 @@ type AccountHeaderProps = { accountsSyncing: string[]; failedAccounts: AccountSyncSidebarProps['failedAccounts']; accounts: AccountEntity[]; - transactions: TransactionEntity[]; + transactions: readonly TransactionEntity[]; showBalances: boolean; showExtraBalances: boolean; showCleared: boolean; showReconciled: boolean; showEmptyMessage: boolean; - balanceQuery: ComponentProps['balanceQuery']; + balanceQuery: Query; + filteredQuery: Query; reconcileAmount: number; canCalculateBalance: () => boolean; - isFiltered: boolean; - filteredAmount: number; + showFilteredBalance: boolean; + filteredBalance: number; isSorted: boolean; search: string; filterConditions: RuleConditionEntity[]; @@ -127,7 +130,8 @@ export function AccountHeader({ tableRef, editingName, isNameEditable, - workingHard, + isLoading, + accountId, accountName, account, filterId, @@ -141,13 +145,14 @@ export function AccountHeader({ showCleared, showReconciled, showEmptyMessage, + // transactionsQuery, balanceQuery, reconcileAmount, - canCalculateBalance, - isFiltered, - filteredAmount, + // canCalculateBalance, + showFilteredBalance, + filteredBalance, isSorted, - search, + // search, filterConditions, filterConditionsOp, onSearch, @@ -191,6 +196,7 @@ export function AccountHeader({ const isUsingServer = syncServerStatus !== 'no-server'; const isServerOffline = syncServerStatus === 'offline'; const [_, setExpandSplitsPref] = useLocalPref('expand-splits'); + const [search, setSearch] = useState(''); let canSync = !!(account?.account_id && isUsingServer); if (!account) { @@ -287,12 +293,13 @@ export function AccountHeader({ { + setSearch(search); + onSearch?.(search); + }} inputRef={searchInput} /> - {workingHard ? ( + transactions.find(t => t.id === id)} + onShow={onShowTransactions} + onDuplicate={onBatchDuplicate} + onDelete={onBatchDelete} + onEdit={onBatchEdit} + onLinkSchedule={onBatchLinkSchedule} + onUnlinkSchedule={onBatchUnlinkSchedule} + onCreateRule={onCreateRule} + onSetTransfer={onSetTransfer} + onScheduleAction={onScheduleAction} + showMakeTransfer={showMakeTransfer} + onMakeAsSplitTransaction={onMakeAsSplitTransaction} + onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions} + /> + + {account && ( - + + setReconcileOpen(false)} + > + setReconcileOpen(false)} + onReconcile={onReconcile} + /> + - ) : ( - transactions.find(t => t.id === id)} - onShow={onShowTransactions} - onDuplicate={onBatchDuplicate} - onDelete={onBatchDelete} - onEdit={onBatchEdit} - onLinkSchedule={onBatchLinkSchedule} - onUnlinkSchedule={onBatchUnlinkSchedule} - onCreateRule={onCreateRule} - onSetTransfer={onSetTransfer} - onScheduleAction={onScheduleAction} - showMakeTransfer={showMakeTransfer} - onMakeAsSplitTransaction={onMakeAsSplitTransaction} - onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions} - /> )} - - {account && ( - <> - - setReconcileOpen(false)} - > - setReconcileOpen(false)} - onReconcile={onReconcile} - /> - - - )} - + void; onMakeAsNonSplitTransactions: (selectedIds: string[]) => void; + isLoading?: boolean; }; export function SelectedTransactionsButton({ @@ -55,6 +56,7 @@ export function SelectedTransactionsButton({ showMakeTransfer, onMakeAsSplitTransaction, onMakeAsNonSplitTransactions, + isLoading = false, }: SelectedTransactionsButtonProps) { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -206,6 +208,7 @@ export function SelectedTransactionsButton({ return ( t('{{count}} transactions', { count })} // @ts-expect-error fix me items={[ diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 0a7784e0472..1a15763f3b3 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -57,6 +57,7 @@ async function saveDiffAndApply(diff, changes, onChange) { } export function TransactionList({ + isLoading = false, tableRef, transactions, allTransactions, @@ -101,7 +102,6 @@ export function TransactionList({ newTransactions = realizeTempTransactions(newTransactions); await saveDiff({ added: newTransactions }); - onRefetch(); }, []); const onSave = useCallback(async transaction => { @@ -198,6 +198,7 @@ export function TransactionList({ return ( ( name: string, - items: T[], - initialSelectedIds: string[], + items: readonly T[], + initialSelectedIds: readonly string[], selectAllFilter?: (item: T) => boolean, ) { const [state, dispatch] = useReducer( @@ -309,8 +309,8 @@ export function SelectedProvider({ type SelectedProviderWithItemsProps = { name: string; - items: T[]; - initialSelectedIds?: string[]; + items: readonly T[]; + initialSelectedIds?: readonly string[]; fetchAllIds: () => Promise; registerDispatch?: (dispatch: Dispatch) => void; selectAllFilter?: (item: T) => boolean; diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index 5572446d9ed..61c708051b6 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -90,7 +90,10 @@ export function recalculateSplit(trans: TransactionEntity) { } as TransactionEntityWithError; } -function findParentIndex(transactions: TransactionEntity[], idx: number) { +function findParentIndex( + transactions: readonly TransactionEntity[], + idx: number, +) { // This relies on transactions being sorted in a way where parents // are always before children, which is enforced in the db layer. // Walk backwards and find the last parent; @@ -104,7 +107,10 @@ function findParentIndex(transactions: TransactionEntity[], idx: number) { return null; } -function getSplit(transactions: TransactionEntity[], parentIndex: number) { +function getSplit( + transactions: readonly TransactionEntity[], + parentIndex: number, +) { const split = [transactions[parentIndex]]; let curr = parentIndex + 1; while (curr < transactions.length && transactions[curr].is_child) { @@ -114,7 +120,9 @@ function getSplit(transactions: TransactionEntity[], parentIndex: number) { return split; } -export function ungroupTransactions(transactions: TransactionEntity[]) { +export function ungroupTransactions( + transactions: readonly TransactionEntity[], +) { return transactions.reduce((list, parent) => { const { subtransactions, ...trans } = parent; const _subtransactions = subtransactions || []; @@ -128,7 +136,7 @@ export function ungroupTransactions(transactions: TransactionEntity[]) { }, []); } -export function groupTransaction(split: TransactionEntity[]) { +export function groupTransaction(split: readonly TransactionEntity[]) { return { ...split[0], subtransactions: split.slice(1) } as TransactionEntity; } @@ -152,7 +160,7 @@ export function applyTransactionDiff( } function replaceTransactions( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], id: string, func: ( transaction: TransactionEntity, @@ -218,7 +226,7 @@ function replaceTransactions( } export function addSplitTransaction( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], id: string, ) { return replaceTransactions(transactions, id, trans => { @@ -237,7 +245,7 @@ export function addSplitTransaction( } export function updateTransaction( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], transaction: TransactionEntity, ) { return replaceTransactions(transactions, transaction.id, trans => { @@ -270,7 +278,7 @@ export function updateTransaction( } export function deleteTransaction( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], id: string, ) { return replaceTransactions(transactions, id, trans => { @@ -295,7 +303,7 @@ export function deleteTransaction( } export function splitTransaction( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], id: string, createSubtransactions?: ( parentTransaction: TransactionEntity, @@ -323,7 +331,7 @@ export function splitTransaction( } export function realizeTempTransactions( - transactions: TransactionEntity[], + transactions: readonly TransactionEntity[], ): TransactionEntity[] { const parent = { ...transactions.find(t => !t.is_child), @@ -344,8 +352,8 @@ export function realizeTempTransactions( } export function makeAsNonChildTransactions( - childTransactionsToUpdate: TransactionEntity[], - transactions: TransactionEntity[], + childTransactionsToUpdate: readonly TransactionEntity[], + transactions: readonly TransactionEntity[], ) { const [parentTransaction, ...childTransactions] = transactions; const newNonChildTransactions = childTransactionsToUpdate.map(t =>