import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Redirect, useParams, useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'debounce'; import { bindActionCreators } from 'redux'; 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 q, { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { deleteTransaction, updateTransaction, ungroupTransactions } from 'loot-core/src/shared/transactions'; import { currencyToInteger, applyChanges, groupById } from 'loot-core/src/shared/util'; import { View, Text, Button, Input, InputWithContent, InitialFocus, Tooltip, Menu, Stack } from 'loot-design/src/components/common'; import { KeyHandlers } from 'loot-design/src/components/KeyHandlers'; import NotesButton from 'loot-design/src/components/NotesButton'; import CellValue from 'loot-design/src/components/spreadsheet/CellValue'; import format from 'loot-design/src/components/spreadsheet/format'; import useSheetValue from 'loot-design/src/components/spreadsheet/useSheetValue'; import { SelectedItemsButton } from 'loot-design/src/components/table'; import { SelectedProviderWithItems, useSelectedItems } from 'loot-design/src/components/useSelected'; import { styles, colors } from 'loot-design/src/style'; import Add from 'loot-design/src/svg/v1/Add'; import Loading from 'loot-design/src/svg/v1/AnimatedLoading'; import DotsHorizontalTriple from 'loot-design/src/svg/v1/DotsHorizontalTriple'; import ArrowButtonRight1 from 'loot-design/src/svg/v2/ArrowButtonRight1'; import ArrowsExpand3 from 'loot-design/src/svg/v2/ArrowsExpand3'; import ArrowsShrink3 from 'loot-design/src/svg/v2/ArrowsShrink3'; import CheckCircle1 from 'loot-design/src/svg/v2/CheckCircle1'; import DownloadThickBottom from 'loot-design/src/svg/v2/DownloadThickBottom'; import Pencil1 from 'loot-design/src/svg/v2/Pencil1'; import SearchAlternate from 'loot-design/src/svg/v2/SearchAlternate'; import { authorizeBank } from '../../plaid'; import { useActiveLocation } from '../ActiveLocation'; import AnimatedRefresh from '../AnimatedRefresh'; import { FilterButton, AppliedFilters } from './Filters'; import TransactionList from './TransactionList'; import { SplitsExpandedProvider, useSplitsExpanded, isPreviewId } from './TransactionsTable'; function EmptyMessage({ onAdd }) { return ( For Actual to be useful, you need to add an account. You can link an account to automatically download transactions, or manage it locally yourself. In the future, you can add accounts from the sidebar. ); } function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) { let cleared = useSheetValue({ name: balanceQuery.name + '-cleared', value: 0, query: balanceQuery.query.filter({ cleared: true }) }); let targetDiff = targetBalance - cleared; return ( {targetDiff === 0 ? ( All reconciled! ) : ( Your cleared balance{' '} {format(cleared, 'financial')} needs{' '} {(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')} {' '} to match
your bank{"'"}s balance of{' '} {format(targetBalance, 'financial')}
)}
); } function ReconcileTooltip({ account, onReconcile, onClose }) { let balance = useSheetValue(queries.accountBalance(account)); function onSubmit(e) { let input = e.target.elements[0]; let amount = currencyToInteger(input.value); onReconcile(amount == null ? balance : amount); onClose(); } return ( Enter the current balance of your bank account that you want to reconcile with:
{balance != null && ( )}
); } function MenuButton({ onClick }) { return ( ); } function MenuTooltip({ onClose, children }) { return ( {children} ); } function AccountMenu({ account, canSync, syncEnabled, showBalances, canShowBalances, onClose, onReconcile, onMenuSelect }) { let [tooltip, setTooltip] = useState('default'); return tooltip === 'reconcile' ? ( ) : ( { if (item === 'reconcile') { setTooltip('reconcile'); } else { onMenuSelect(item); } }} items={[ canShowBalances && { name: 'toggle-balance', text: (showBalances ? 'Hide' : 'Show') + ' Running Balance' }, { name: 'export', text: 'Export' }, { name: 'reconcile', text: 'Reconcile' }, syncEnabled && account && !account.closed && (canSync ? { name: 'unlink', text: 'Unlink Account' } : { name: 'link', text: 'Link Account' }), account.closed ? { name: 'reopen', text: 'Reopen Account' } : { name: 'close', text: 'Close Account' } ].filter(x => x)} /> ); } function CategoryMenu({ onClose, onMenuSelect }) { return ( { onMenuSelect(item); }} items={[{ name: 'export', text: 'Export' }]} /> ); } function DetailedBalance({ name, balance }) { return ( {name}{' '} {format(balance, 'financial')} ); } function SelectedBalance({ selectedItems }) { let [balance, setBalance] = useState(null); useEffect(() => { async function run() { let { data: rows } = await runQuery( q('transactions') .filter({ id: { $oneof: [...selectedItems] }, parent_id: { $oneof: [...selectedItems] } }) .select('id') ); let ids = new Set(rows.map(r => r.id)); let finalIds = [...selectedItems].filter(id => !ids.has(id)); let { data: balance } = await runQuery( q('transactions') .filter({ id: { $oneof: finalIds } }) .options({ splits: 'all' }) .calculate({ $sum: '$amount' }) ); setBalance(balance); } run(); }, [selectedItems]); if (balance == null) { return null; } return ; } function MoreBalances({ balanceQuery }) { let cleared = useSheetValue({ name: balanceQuery.name + '-cleared', query: balanceQuery.query.filter({ cleared: true }) }); let uncleared = useSheetValue({ name: balanceQuery.name + '-uncleared', query: balanceQuery.query.filter({ cleared: false }) }); return ( ); } function Balances({ balanceQuery, showExtraBalances, onToggleExtraBalances }) { let selectedItems = useSelectedItems(); return ( {showExtraBalances && } {selectedItems.size > 0 && ( )} ); } // function ScheduleMenu({ onSelect, onClose }) { // let params = useParams(); // let scheduleData = useCachedSchedules(); // let payees = useSelector(state => state.queries.payees); // let byId = getPayeesById(payees); // if (scheduleData == null) { // return null; // } // return ( // // { // onSelect(name); // onClose(); // }} // items={scheduleData.schedules.map(s => { // let desc = s._payee // ? `${byId[s._payee].name} (${s.next_date})` // : `No payee (${s.next_date})`; // return { name: s.id, text: desc }; // })} // /> // // ); // } function SelectedTransactionsButton({ style, getTransaction, onShow, onDelete, onEdit, onUnlink, onScheduleAction }) { let selectedItems = useSelectedItems(); let history = useHistory(); let types = useMemo(() => { let items = [...selectedItems]; return { preview: !!items.find(id => isPreviewId(id)), trans: !!items.find(id => !isPreviewId(id)) }; }, [selectedItems]); let linked = useMemo(() => { return ( !types.preview && [...selectedItems].every(id => { let t = getTransaction(id); return t && t.schedule; }) ); }, [types.preview, selectedItems, getTransaction]); function getRealTransactions() { return [...selectedItems].filter(id => !isPreviewId(id)); } return ( onShow([...selectedItems]), d: () => onDelete([...selectedItems]), a: () => onEdit('account', [...selectedItems]), p: () => onEdit('payee', [...selectedItems]), n: () => onEdit('notes', [...selectedItems]), c: () => onEdit('category', [...selectedItems]), l: () => onEdit('cleared', [...selectedItems]) } } items={[ ...(!types.trans ? [ { name: 'view-schedule', text: 'View schedule' }, { name: 'post-transaction', text: 'Post transaction' }, { name: 'skip', text: 'Skip scheduled date' } ] : [ { name: 'show', text: 'Show', key: 'F' }, { name: 'delete', text: 'Delete', key: 'D' }, ...(linked ? [ { name: 'view-schedule', text: 'View schedule', disabled: selectedItems.size > 1 }, { name: 'unlink-schedule', text: 'Unlink schedule' } ] : [ { name: 'link-schedule', text: 'Link schedule' } ]), Menu.line, { type: Menu.label, name: 'Edit field' }, { name: 'date', text: 'Date' }, { name: 'account', text: 'Account', key: 'A' }, { name: 'payee', text: 'Payee', key: 'P' }, { name: 'notes', text: 'Notes', key: 'N' }, { name: 'category', text: 'Category', key: 'C' }, { name: 'amount', text: 'Amount' }, { name: 'cleared', text: 'Cleared', key: 'L' } ]) ]} onSelect={name => { switch (name) { case 'show': onShow([...selectedItems]); break; case 'delete': onDelete([...selectedItems]); break; case 'post-transaction': case 'skip': onScheduleAction(name, selectedItems); break; case 'view-schedule': let firstId = [...selectedItems][0]; let scheduleId; if (isPreviewId(firstId)) { let parts = firstId.split('/'); scheduleId = parts[1]; } else { let trans = getTransaction(firstId); scheduleId = trans && trans.schedule; } if (scheduleId) { history.push(`/schedule/edit/${scheduleId}`, { locationPtr: history.location }); } break; case 'link-schedule': history.push(`/schedule/link`, { locationPtr: history.location, transactionIds: [...selectedItems] }); break; case 'unlink-schedule': onUnlink([...selectedItems]); break; default: let field = name; onEdit(name, [...selectedItems]); } }} > ); } const AccountHeader = React.memo( ({ tableRef, editingName, isNameEditable, workingHard, accountName, account, accountsSyncing, accounts, transactions, syncEnabled, showBalances, showExtraBalances, showEmptyMessage, balanceQuery, reconcileAmount, canCalculateBalance, search, filters, savePrefs, onSearch, onAddTransaction, onShowTransactions, onDoneReconciling, onToggleExtraBalances, onSaveName, onExposeName, onSync, onImport, onMenuSelect, onReconcile, onBatchDelete, onBatchEdit, onBatchUnlink, onApplyFilter, onDeleteFilter, onScheduleAction }) => { let [menuOpen, setMenuOpen] = useState(false); let searchInput = useRef(null); let splitsExpanded = useSplitsExpanded(); let canSync = syncEnabled && account && account.account_id; if (!account) { // All accounts - check for any syncable account canSync = !!accounts.find(account => !!account.account_id); } function onToggleSplits() { if (tableRef.current) { splitsExpanded.dispatch({ type: 'switch-mode', id: tableRef.current.getScrolledItem() }); savePrefs({ 'expand-splits': !(splitsExpanded.state.mode === 'expand') }); } } return ( <> { if (searchInput.current) { searchInput.current.focus(); } } }} /> {editingName ? ( onSaveName(e.target.value)} onBlur={() => onExposeName(false)} style={{ fontSize: 25, fontWeight: 500, marginTop: -5, marginBottom: -2, marginLeft: -5 }} /> ) : isNameEditable ? ( {accountName} ) : ( {accountName} )} {((account && !account.closed) || canSync) && ( )} {!showEmptyMessage && ( )} } inputRef={searchInput} value={search} placeholder="Search" getStyle={focused => [ { backgroundColor: 'transparent', borderWidth: 0, boxShadow: 'none', transition: 'color .15s', '& input::placeholder': { color: colors.n1, transition: 'color .25s' } }, focused && { boxShadow: '0 0 0 2px ' + colors.b5 }, !focused && search !== '' && { color: colors.p4 } ]} onChange={e => onSearch(e.target.value)} /> {workingHard ? ( ) : ( transactions.find(t => t.id === id)} onShow={onShowTransactions} onDelete={onBatchDelete} onEdit={onBatchEdit} onUnlink={onBatchUnlink} onScheduleAction={onScheduleAction} /> )} {account ? ( setMenuOpen(true)} /> {menuOpen && ( { setMenuOpen(false); onMenuSelect(item); }} onReconcile={onReconcile} onClose={() => setMenuOpen(false)} /> )} ) : ( setMenuOpen(true)} /> {menuOpen && ( { setMenuOpen(false); onMenuSelect(item); }} onClose={() => setMenuOpen(false)} /> )} )} {filters && filters.length > 0 && ( )} {reconcileAmount != null && ( )} ); } ); function AllTransactions({ transactions, filtered, children }) { let scheduleData = useCachedSchedules(); let schedules = useMemo( () => scheduleData ? scheduleData.schedules.filter( s => !s.completed && ['due', 'upcoming', 'missed'].includes( scheduleData.statuses.get(s.id) ) ) : [], [scheduleData] ); let 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 })); }, [schedules]); let 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]); if (scheduleData == null) { return children(null); } return children(allTransactions); } class AccountInternal extends React.PureComponent { constructor(props) { super(props); this.paged = null; this.table = React.createRef(); this.animated = true; this.state = { search: '', filters: [], loading: true, workingHard: false, reconcileAmount: null, transactions: [], transactionsCount: 0, showBalances: props.showBalances, balances: [], editingName: false, isAdding: false, latestDate: null }; } async componentDidMount() { let maybeRefetch = tables => { if ( tables.includes('transactions') || tables.includes('category_mapping') || tables.includes('payee_mapping') ) { return this.refetchTransactions(); } }; let onUndo = async ({ tables, messages, undoTag }) => { await maybeRefetch(tables); // If all the messages are dealing with transactions, find the // first message referencing a non-deleted row so that we can // highlight the row // let focusId; if ( messages.every(msg => msg.dataset === 'transactions') && !messages.find(msg => msg.column === 'tombstone') ) { let focusableMsgs = messages.filter( msg => msg.dataset === 'transactions' && !(msg.column === 'tombstone') ); focusId = focusableMsgs.length === 1 ? focusableMsgs[0].row : null; // Highlight the transactions // this.table && this.table.highlight(focusableMsgs.map(msg => msg.row)); } if (this.table.current) { this.table.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); } } this.props.setLastUndoState(null); }; let unlistens = [listen('undo-event', onUndo)]; this.unlisten = () => { unlistens.forEach(unlisten => unlisten()); }; // Important that any async work happens last so that the // listeners are set up synchronously if (this.props.categoryGroups.length === 0) { await this.props.getCategories(); } await this.props.initiallyLoadPayees(); await this.fetchTransactions(); // 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) { // 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); } } componentWillUnmount() { if (this.unlisten) { this.unlisten(); } if (this.paged) { this.paged.unsubscribe(); } } fetchAllIds = async () => { let { data } = await runQuery(this.paged.getQuery().select('id')); // Remember, this is the `grouped` split type so we need to deal // with the `subtransactions` property return data.reduce((arr, t) => { arr.push(t.id); t.subtransactions.forEach(sub => arr.push(sub.id)); return arr; }, []); }; refetchTransactions = async () => { this.paged && this.paged.run(); }; fetchTransactions = () => { let query = this.makeRootQuery(); this.rootQuery = this.currentQuery = query; this.updateQuery(query); if (this.props.accountId) { this.props.markAccountRead(this.props.accountId); } }; makeRootQuery = () => { let { transactions } = this.state; let locationState = this.props.location.state; let accountId = this.props.accountId; if (locationState && locationState.filter) { return q('transactions') .options({ splits: 'grouped' }) .filter({ 'account.offbudget': false, ...locationState.filter }); } return queries.makeTransactionsQuery(accountId); }; updateQuery(query, isFiltered) { if (this.paged) { this.paged.unsubscribe(); } this.paged = pagedQuery( query.select('*'), async (data, prevData) => { const firstLoad = prevData == null; if (firstLoad) { this.table.current && this.table.current.setRowAnimation(false); if (isFiltered) { this.props.splitsExpandedDispatch({ type: 'set-mode', mode: 'collapse' }); } else { this.props.splitsExpandedDispatch({ type: 'set-mode', mode: this.props.expandSplits ? 'expand' : 'collapse' }); } } this.setState( { transactions: data, transactionCount: this.paged.getTotalCount(), transactionsFiltered: isFiltered, loading: false, workingHard: false }, () => { if (this.state.showBalances) { this.calculateBalances(); } if (firstLoad) { this.table.current && this.table.current.scrollToTop(); } setTimeout(() => { this.table.current && this.table.current.setRowAnimation(true); }, 0); } ); }, { pageCount: 150, onlySync: true, mapper: ungroupTransactions } ); } componentWillReceiveProps(nextProps) { if (this.props.match !== nextProps.match) { this.setState( { editingName: false, loading: true, search: '', showBalances: nextProps.showBalances, balances: [] }, () => { this.fetchTransactions(); } ); } } onSearch = value => { this.paged.unsubscribe(); this.setState({ search: value }, this.onSearchDone); }; onSearchDone = debounce(() => { if (this.state.search === '') { this.updateQuery(this.currentQuery, this.state.filters.length > 0); } else { this.updateQuery( queries.makeTransactionSearchQuery( this.currentQuery, this.state.search, this.props.dateFormat ), true ); } }, 150); onSync = async () => { const accountId = this.props.accountId; const account = this.props.accounts.find(acct => acct.id === accountId); await this.props.syncAndDownload(account ? account.id : null); }; onImport = async () => { const accountId = this.props.accountId; const account = this.props.accounts.find(acct => acct.id === accountId); if (account) { const res = await window.Actual.openFileDialog({ filters: [ { name: 'Financial Files', extensions: ['qif', 'ofx', 'qfx', 'csv'] } ] }); if (res) { this.props.pushModal('import-transactions', { accountId, filename: res[0], onImported: didChange => { if (didChange) { this.fetchTransactions(); } } }); } } }; onExport = async accountName => { let exportedTransactions = await send('transactions-export-query', { query: this.currentQuery.serialize() }); let normalizedName = accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-'); let filename = `${normalizedName || 'transactions'}.csv`; window.Actual.saveFile( exportedTransactions, filename, 'Export Transactions' ); }; onTransactionsChange = (newTransaction, data) => { // Apply changes to pagedQuery data this.paged.optimisticUpdate( data => { if (newTransaction._deleted) { return data.filter(t => t.id !== newTransaction.id); } else { return data.map(t => { return t.id === newTransaction.id ? newTransaction : t; }); } }, mappedData => { return data; } ); this.props.updateNewTransactions(newTransaction.id); }; canCalculateBalance = () => { let accountId = this.props.accountId; let account = this.props.accounts.find(account => account.id === accountId); return ( account && this.state.search === '' && this.state.filters.length === 0 ); }; async calculateBalances() { if (!this.canCalculateBalance()) { return; } let { data } = await runQuery( this.paged .getQuery() .options({ splits: 'none' }) .select([{ balance: { $sumOver: '$amount' } }]) ); this.setState({ balances: groupById(data) }); } onAddTransaction = () => { this.setState({ isAdding: true }); }; onExposeName = flag => { this.setState({ editingName: flag }); }; onSaveName = name => { const accountId = this.props.accountId; const account = this.props.accounts.find( account => account.id === accountId ); this.props.updateAccount({ ...account, name }); this.setState({ editingName: false }); }; onToggleExtraBalances = () => { let { accountId, showExtraBalances } = this.props; let key = 'show-extra-balances-' + accountId; this.props.savePrefs({ [key]: !showExtraBalances }); }; onMenuSelect = async item => { const accountId = this.props.accountId; const account = this.props.accounts.find( account => account.id === accountId ); switch (item) { case 'link': authorizeBank(this.props.pushModal, { upgradingId: accountId }); break; case 'unlink': 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.savePrefs({ ['show-balances-' + accountId]: false }); this.setState({ showBalances: false, balances: [] }); } else { this.props.savePrefs({ ['show-balances-' + accountId]: true }); this.setState({ showBalances: true }); this.calculateBalances(); } break; default: } }; getAccountTitle(account, id) { let { filterName } = this.props.location.state || {}; if (filterName) { return filterName; } if (!account) { if (id === 'budgeted') { return 'Budgeted Accounts'; } else if (id === 'offbudget') { return 'Off Budget Accounts'; } else if (id === 'uncategorized') { return 'Uncategorized'; } else if (!id) { return 'All Accounts'; } return null; } return (account.closed ? 'Closed: ' : '') + account.name; } getBalanceQuery(account, id) { return { name: `balance-query-${id}`, query: this.makeRootQuery().calculate({ $sum: '$amount' }) }; } isNew = id => { return this.props.newTransactions.includes(id); }; isMatched = id => { return this.props.matchedTransactions.includes(id); }; onManagePayees = id => { this.props.pushModal('manage-payees', { selectedPayee: id }); }; onCreatePayee = name => { let trimmed = name.trim(); if (trimmed !== '') { return this.props.createPayee(name); } return null; }; onReconcile = balance => { this.setState({ reconcileAmount: balance }); }; onDoneReconciling = () => { this.setState({ reconcileAmount: null }); }; onShowTransactions = async ids => { this.onApplyFilter({ customName: 'Selected transactions', filter: { id: { $oneof: ids } } }); }; onBatchEdit = async (name, ids) => { let onChange = async (name, value) => { this.setState({ workingHard: true }); let { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids } }) .select('*') .options({ splits: 'grouped' }) ); let transactions = ungroupTransactions(data); let changes = { deleted: [], updated: [] }; // Cleared is a special case right now if (name === 'cleared') { // Clear them if any are uncleared, otherwise unclear them value = !!transactions.find(t => !t.cleared); } transactions.forEach(trans => { let { diff } = updateTransaction(transactions, { ...trans, [name]: value }); // TODO: We need to keep an updated list of transactions so // the logic in `updateTransaction`, particularly about // updating split transactions, works. This isn't ideal and we // should figure something else out transactions = applyChanges(diff, transactions); changes.deleted = changes.deleted ? changes.deleted.concat(diff.deleted) : diff.deleted; changes.updated = changes.updated ? changes.updated.concat(diff.updated) : diff.updated; changes.added = changes.added ? changes.added.concat(diff.added) : diff.added; }); await send('transactions-batch-update', changes); await this.refetchTransactions(); if (this.table.current) { this.table.current.edit(transactions[0].id, 'select', false); } }; if (name === 'cleared') { // Cleared just toggles it on/off and it depends on the data // loaded. Need to clean this up in the future. onChange('cleared', null); } else { this.props.pushModal('edit-field', { name, onSubmit: onChange }); } }; onBatchDelete = async ids => { this.setState({ workingHard: true }); let { data } = await runQuery( q('transactions') .filter({ id: { $oneof: ids } }) .select('*') .options({ splits: 'grouped' }) ); let transactions = ungroupTransactions(data); let idSet = new Set(ids); let changes = { deleted: [], updated: [] }; transactions.forEach(trans => { let parentId = trans.parent_id; // First, check if we're actually deleting this transaction by // checking `idSet`. Then, we don't need to do anything if it's // a child transaction and the parent is already being deleted if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) { return; } let { diff } = deleteTransaction(transactions, trans.id); // TODO: We need to keep an updated list of transactions so // the logic in `updateTransaction`, particularly about // updating split transactions, works. This isn't ideal and we // should figure something else out transactions = applyChanges(diff, transactions); changes.deleted = diff.deleted ? changes.deleted.concat(diff.deleted) : diff.deleted; changes.updated = diff.updated ? changes.updated.concat(diff.updated) : diff.updated; }); await send('transactions-batch-update', changes); await this.refetchTransactions(); }; onBatchUnlink = async ids => { await send('transactions-batch-update', { updated: ids.map(id => ({ id, schedule: null })) }); await this.refetchTransactions(); }; onDeleteFilter = filter => { this.applyFilters(this.state.filters.filter(f => f !== filter)); }; onApplyFilter = async cond => { let filters = this.state.filters; if (cond.customName) { filters = filters.filter(f => f.customName !== cond.customName); } this.applyFilters([...filters, cond]); }; onScheduleAction = async (name, ids) => { switch (name) { case 'post-transaction': for (let id of ids) { let parts = id.split('/'); await send('schedule/post-transaction', { id: parts[1] }); } this.refetchTransactions(); break; case 'skip': for (let id of ids) { let parts = id.split('/'); await send('schedule/skip-next-date', { id: parts[1] }); } break; default: } }; applyFilters = async conditions => { if (conditions.length > 0) { let customFilters = conditions .filter(cond => !!cond.customName) .map(f => f.filter); let { filters } = await send('make-filters-from-conditions', { conditions: conditions.filter(cond => !cond.customName) }); this.currentQuery = this.rootQuery.filter({ $and: [...filters, ...customFilters] }); this.updateQuery(this.currentQuery, true); this.setState({ filters: conditions, search: '' }); } else { this.setState({ transactions: [], transactionCount: 0 }); this.fetchTransactions(); this.setState({ filters: conditions, search: '' }); } }; render() { let { accounts, categoryGroups, payees, match, syncEnabled, dateFormat, addNotification, accountsSyncing, replaceModal, showExtraBalances, expandSplits, accountId } = this.props; let { transactions, loading, workingHard, reconcileAmount, transactionCount, transactionsFiltered, editingName, showBalances, balances } = this.state; let 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 ; } let showEmptyMessage = !loading && !accountId && accounts.length === 0; let isNameEditable = accountId && accountId !== 'budgeted' && accountId !== 'offbudget' && accountId !== 'uncategorized'; let balanceQuery = this.getBalanceQuery(account, accountId); return ( {allTransactions => allTransactions == null ? null : ( (this.dispatchSelected = dispatch)} > this.paged && this.paged.fetchNext() } accounts={accounts} categoryGroups={categoryGroups} payees={payees} balances={ showBalances && this.canCalculateBalance() ? balances : null } showAccount={ !accountId || accountId === 'offbudget' || accountId === 'budgeted' || accountId === 'uncategorized' } isAdding={this.state.isAdding} isNew={this.isNew} isMatched={this.isMatched} isFiltered={ this.state.search !== '' || this.state.filters.length > 0 } dateFormat={dateFormat} addNotification={addNotification} renderEmpty={() => showEmptyMessage ? ( replaceModal( syncEnabled ? 'add-account' : 'add-local-account' ) } /> ) : !loading ? ( No transactions ) : null } onChange={this.onTransactionsChange} onRefetch={this.refetchTransactions} onRefetchUpToRow={row => this.paged.refetchUpToRow(row, { field: 'date', order: 'desc' }) } onCloseAddTransaction={() => this.setState({ isAdding: false }) } onManagePayees={this.onManagePayees} onCreatePayee={this.onCreatePayee} /> ) } ); } } function AccountHack(props) { let { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); return ( ); } export default function Account(props) { let state = useSelector(state => ({ newTransactions: state.queries.newTransactions, matchedTransactions: state.queries.matchedTransactions, accounts: state.queries.accounts, failedAccounts: state.account.failedAccounts, categoryGroups: state.queries.categories.grouped, syncEnabled: state.prefs.local['flags.syncAccount'], dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', expandSplits: props.match && state.prefs.local['expand-splits'], showBalances: props.match && state.prefs.local['show-balances-' + props.match.params.id], showExtraBalances: props.match && state.prefs.local['show-extra-balances-' + props.match.params.id], payees: state.queries.payees, modalShowing: state.modals.modalStack.length > 0, accountsSyncing: state.account.accountsSyncing, lastUndoState: state.app.lastUndoState, tutorialStage: state.tutorial.stage })); let dispatch = useDispatch(); let actionCreators = useMemo(() => bindActionCreators(actions, dispatch), [ dispatch ]); let params = useParams(); let location = useLocation(); let activeLocation = useActiveLocation(); let transform = useMemo(() => { let filter = queries.getAccountFilter(params.id, '_account'); // Never show schedules on these pages if ( (location.state && location.state.filter) || params.id === 'uncategorized' ) { filter = { id: null }; } return q => { q = q.filter({ $and: [filter, { '_account.closed': false }] }); return q.orderBy({ next_date: 'desc' }); }; }, [params.id]); return ( ); }