diff --git a/packages/desktop-client/src/components/accounts/Account.js b/packages/desktop-client/src/components/accounts/Account.js index ab0105a217edf99ecd813dc8d133db8bbadc3151..d67b3cb3c8d364bfd54b0baa43020e982cccd7b1 100644 --- a/packages/desktop-client/src/components/accounts/Account.js +++ b/packages/desktop-client/src/components/accounts/Account.js @@ -1,11 +1,4 @@ -import React, { - PureComponent, - createRef, - memo, - useState, - useRef, - useMemo, -} from 'react'; +import React, { PureComponent, createRef, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom'; @@ -29,59 +22,20 @@ import { ungroupTransaction, ungroupTransactions, } from 'loot-core/src/shared/transactions'; -import { - currencyToInteger, - applyChanges, - groupById, -} from 'loot-core/src/shared/util'; +import { applyChanges, groupById } from 'loot-core/src/shared/util'; -import { - SelectedProviderWithItems, - useSelectedItems, -} from '../../hooks/useSelected'; -import useSyncServerStatus from '../../hooks/useSyncServerStatus'; -import Loading from '../../icons/AnimatedLoading'; -import Add from '../../icons/v1/Add'; -import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple'; -import ArrowButtonRight1 from '../../icons/v2/ArrowButtonRight1'; -import ArrowsExpand3 from '../../icons/v2/ArrowsExpand3'; -import ArrowsShrink3 from '../../icons/v2/ArrowsShrink3'; -import CheckCircle1 from '../../icons/v2/CheckCircle1'; -import DownloadThickBottom from '../../icons/v2/DownloadThickBottom'; -import Pencil1 from '../../icons/v2/Pencil1'; -import SvgRemove from '../../icons/v2/Remove'; -import SearchAlternate from '../../icons/v2/SearchAlternate'; +import { SelectedProviderWithItems } from '../../hooks/useSelected'; import { authorizeBank } from '../../nordigen'; import { styles, colors } from '../../style'; -import { usePushModal } from '../../util/router-tools'; import { useActiveLocation } from '../ActiveLocation'; -import AnimatedRefresh from '../AnimatedRefresh'; -import { - View, - Text, - Button, - Input, - InputWithContent, - InitialFocus, - Tooltip, - Menu, - Stack, -} from '../common'; -import { FilterButton } from '../filters/FiltersMenu'; -import { FiltersStack } from '../filters/SavedFilters'; -import { KeyHandlers } from '../KeyHandlers'; -import NotesButton from '../NotesButton'; -import CellValue from '../spreadsheet/CellValue'; -import format from '../spreadsheet/format'; -import useSheetValue from '../spreadsheet/useSheetValue'; -import { SelectedItemsButton } from '../table'; - -import TransactionList from './TransactionList'; +import { View, Text, Button } from '../common'; +import TransactionList from '../transactions/TransactionList'; import { SplitsExpandedProvider, useSplitsExpanded, - isPreviewId, -} from './TransactionsTable'; +} from '../transactions/TransactionsTable'; + +import { AccountHeader } from './Header'; function EmptyMessage({ onAdd }) { return ( @@ -120,886 +74,6 @@ function EmptyMessage({ onAdd }) { ); } -function ReconcilingMessage({ - balanceQuery, - targetBalance, - onDone, - onCreateTransaction, -}) { - let cleared = useSheetValue({ - name: balanceQuery.name + '-cleared', - value: 0, - query: balanceQuery.query.filter({ cleared: true }), - }); - let targetDiff = targetBalance - cleared; - - return ( - <View - style={{ - flexDirection: 'row', - alignSelf: 'center', - backgroundColor: 'white', - ...styles.shadow, - borderRadius: 4, - marginTop: 5, - marginBottom: 15, - padding: 10, - }} - > - <View style={{ flexDirection: 'row', alignItems: 'center' }}> - {targetDiff === 0 ? ( - <View - style={{ - color: colors.g4, - flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }} - > - <CheckCircle1 - style={{ - width: 13, - height: 13, - color: colors.g5, - marginRight: 3, - }} - /> - All reconciled! - </View> - ) : ( - <View style={{ color: colors.n3 }}> - <Text style={{ fontStyle: 'italic', textAlign: 'center' }}> - Your cleared balance{' '} - <strong>{format(cleared, 'financial')}</strong> needs{' '} - <strong> - {(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')} - </strong>{' '} - to match - <br /> your bank’s balance of{' '} - <Text style={{ fontWeight: 700 }}> - {format(targetBalance, 'financial')} - </Text> - </Text> - </View> - )} - <View style={{ marginLeft: 15 }}> - <Button primary onClick={onDone}> - Done Reconciling - </Button> - </View> - {targetDiff !== 0 && ( - <View style={{ marginLeft: 15 }}> - <Button onClick={() => onCreateTransaction(targetDiff)}> - Create Reconciliation Transaction - </Button> - </View> - )} - </View> - </View> - ); -} - -function ReconcileTooltip({ account, onReconcile, onClose }) { - let balance = useSheetValue(queries.accountBalance(account)); - - function onSubmit(e) { - e.preventDefault(); - let input = e.target.elements[0]; - let amount = currencyToInteger(input.value); - if (amount != null) { - onReconcile(amount == null ? balance : amount); - onClose(); - } else { - input.select(); - } - } - - return ( - <Tooltip position="bottom-right" width={275} onClose={onClose}> - <View style={{ padding: '5px 8px' }}> - <Text> - Enter the current balance of your bank account that you want to - reconcile with: - </Text> - <form onSubmit={onSubmit}> - {balance != null && ( - <InitialFocus> - <Input - defaultValue={format(balance, 'financial')} - style={{ margin: '7px 0' }} - /> - </InitialFocus> - )} - <Button primary>Reconcile</Button> - </form> - </View> - </Tooltip> - ); -} - -function MenuButton({ onClick }) { - return ( - <Button bare onClick={onClick} aria-label="Menu"> - <DotsHorizontalTriple - width={15} - height={15} - style={{ color: 'inherit', transform: 'rotateZ(90deg)' }} - /> - </Button> - ); -} - -export function MenuTooltip({ width, onClose, children }) { - return ( - <Tooltip - position="bottom-right" - width={width} - style={{ padding: 0 }} - onClose={onClose} - > - {children} - </Tooltip> - ); -} - -function AccountMenu({ - account, - canSync, - showBalances, - canShowBalances, - showCleared, - onClose, - onReconcile, - onMenuSelect, -}) { - let [tooltip, setTooltip] = useState('default'); - const syncServerStatus = useSyncServerStatus(); - - return tooltip === 'reconcile' ? ( - <ReconcileTooltip - account={account} - onClose={onClose} - onReconcile={onReconcile} - /> - ) : ( - <MenuTooltip width={200} onClose={onClose}> - <Menu - onMenuSelect={item => { - if (item === 'reconcile') { - setTooltip('reconcile'); - } else { - onMenuSelect(item); - } - }} - items={[ - canShowBalances && { - name: 'toggle-balance', - text: (showBalances ? 'Hide' : 'Show') + ' Running Balance', - }, - { - name: 'toggle-cleared', - text: (showCleared ? 'Hide' : 'Show') + ' “Cleared†Checkboxes', - }, - { name: 'export', text: 'Export' }, - { name: 'reconcile', text: 'Reconcile' }, - account && - !account.closed && - (canSync - ? { - name: 'unlink', - text: 'Unlink Account', - } - : syncServerStatus === 'online' && { - name: 'link', - text: 'Link Account', - }), - account.closed - ? { name: 'reopen', text: 'Reopen Account' } - : { name: 'close', text: 'Close Account' }, - ].filter(x => x)} - /> - </MenuTooltip> - ); -} - -function CategoryMenu({ onClose, onMenuSelect }) { - return ( - <MenuTooltip width={200} onClose={onClose}> - <Menu - onMenuSelect={item => { - onMenuSelect(item); - }} - items={[{ name: 'export', text: 'Export' }]} - /> - </MenuTooltip> - ); -} - -function DetailedBalance({ name, balance }) { - return ( - <Text - style={{ - marginLeft: 15, - backgroundColor: colors.n9, - borderRadius: 4, - padding: '4px 6px', - color: colors.n5, - }} - > - {name}{' '} - <Text style={{ fontWeight: 600 }}>{format(balance, 'financial')}</Text> - </Text> - ); -} - -function SelectedBalance({ selectedItems, account }) { - let name = `selected-balance-${[...selectedItems].join('-')}`; - - let rows = useSheetValue({ - name, - query: 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 balance = useSheetValue({ - name: name + '-sum', - query: q('transactions') - .filter({ id: { $oneof: finalIds } }) - .options({ splits: 'all' }) - .calculate({ $sum: '$amount' }), - }); - - let scheduleBalance = null; - let scheduleData = useCachedSchedules(); - let schedules = scheduleData ? scheduleData.schedules : []; - let previewIds = [...selectedItems] - .filter(id => isPreviewId(id)) - .map(id => id.slice(8)); - for (let s of schedules) { - if (previewIds.includes(s.id)) { - if (!account || account.id === s._account) { - scheduleBalance += s._amount; - } else { - scheduleBalance -= s._amount; - } - } - } - - if (balance == null) { - if (scheduleBalance == null) { - return null; - } else { - balance = scheduleBalance; - } - } else if (scheduleBalance != null) { - balance += scheduleBalance; - } - - return <DetailedBalance name="Selected balance:" balance={balance} />; -} - -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 ( - <View style={{ flexDirection: 'row' }}> - <DetailedBalance name="Cleared total:" balance={cleared} /> - <DetailedBalance name="Uncleared total:" balance={uncleared} /> - </View> - ); -} - -function Balances({ - balanceQuery, - showExtraBalances, - onToggleExtraBalances, - account, -}) { - let selectedItems = useSelectedItems(); - - return ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginLeft: -5, - }} - > - <Button - data-testid="account-balance" - bare - onClick={onToggleExtraBalances} - style={{ - '& svg': { - opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0, - }, - '&:hover svg': { opacity: 1 }, - }} - > - <CellValue - binding={{ ...balanceQuery, value: 0 }} - type="financial" - style={{ fontSize: 22, fontWeight: 400 }} - getStyle={value => ({ - color: value < 0 ? colors.r5 : value > 0 ? colors.g5 : colors.n8, - })} - /> - - <ArrowButtonRight1 - style={{ - width: 10, - height: 10, - marginLeft: 10, - color: colors.n5, - transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)', - }} - /> - </Button> - {showExtraBalances && <MoreBalances balanceQuery={balanceQuery} />} - - {selectedItems.size > 0 && ( - <SelectedBalance selectedItems={selectedItems} account={account} /> - )} - </View> - ); -} - -function SelectedTransactionsButton({ - getTransaction, - onShow, - onDuplicate, - onDelete, - onEdit, - onUnlink, - onCreateRule, - onScheduleAction, -}) { - let pushModal = usePushModal(); - let selectedItems = useSelectedItems(); - - let types = useMemo(() => { - let items = [...selectedItems]; - return { - preview: !!items.find(id => isPreviewId(id)), - trans: !!items.find(id => !isPreviewId(id)), - }; - }, [selectedItems]); - - let ambiguousDuplication = useMemo(() => { - let transactions = [...selectedItems].map(id => getTransaction(id)); - - return transactions.some(t => t && t.is_child); - }, [selectedItems]); - - let linked = useMemo(() => { - return ( - !types.preview && - [...selectedItems].every(id => { - let t = getTransaction(id); - return t && t.schedule; - }) - ); - }, [types.preview, selectedItems, getTransaction]); - - return ( - <SelectedItemsButton - name="transactions" - keyHandlers={ - types.trans && { - f: () => 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: 'duplicate', - text: 'Duplicate', - disabled: ambiguousDuplication, - }, - { 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', - }, - { - name: 'create-rule', - text: 'Create rule', - }, - ]), - 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 'duplicate': - onDuplicate([...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) { - pushModal(`/schedule/edit/${scheduleId}`); - } - break; - case 'link-schedule': - pushModal('/schedule/link', { - transactionIds: [...selectedItems], - }); - break; - case 'unlink-schedule': - onUnlink([...selectedItems]); - break; - case 'create-rule': - onCreateRule([...selectedItems]); - break; - default: - onEdit(name, [...selectedItems]); - } - }} - ></SelectedItemsButton> - ); -} - -const AccountHeader = memo( - ({ - tableRef, - editingName, - isNameEditable, - workingHard, - accountName, - account, - filterId, - filtersList, - accountsSyncing, - accounts, - transactions, - showBalances, - showExtraBalances, - showCleared, - showEmptyMessage, - balanceQuery, - reconcileAmount, - canCalculateBalance, - search, - filters, - conditionsOp, - savePrefs, - onSearch, - onAddTransaction, - onShowTransactions, - onDoneReconciling, - onCreateReconciliationTransaction, - onToggleExtraBalances, - onSaveName, - onExposeName, - onSync, - onImport, - onMenuSelect, - onReconcile, - onBatchDelete, - onBatchDuplicate, - onBatchEdit, - onBatchUnlink, - onCreateRule, - onApplyFilter, - onUpdateFilter, - onClearFilters, - onReloadSavedFilter, - onCondOpChange, - onDeleteFilter, - onScheduleAction, - }) => { - let [menuOpen, setMenuOpen] = useState(false); - let searchInput = useRef(null); - let splitsExpanded = useSplitsExpanded(); - - let canSync = 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 ( - <> - <KeyHandlers - keys={{ - 'ctrl+f, cmd+f': () => { - if (searchInput.current) { - searchInput.current.focus(); - } - }, - }} - /> - - <View - style={[styles.pageContent, { paddingBottom: 10, flexShrink: 0 }]} - > - <View style={{ marginTop: 2, alignItems: 'flex-start' }}> - <View> - {editingName ? ( - <InitialFocus> - <Input - defaultValue={accountName} - onEnter={e => onSaveName(e.target.value)} - onBlur={() => onExposeName(false)} - style={{ - fontSize: 25, - fontWeight: 500, - marginTop: -5, - marginBottom: -2, - marginLeft: -5, - }} - /> - </InitialFocus> - ) : isNameEditable ? ( - <View - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 3, - '& .hover-visible': { - opacity: 0, - transition: 'opacity .25s', - }, - '&:hover .hover-visible': { - opacity: 1, - }, - }} - > - <View - style={{ - fontSize: 25, - fontWeight: 500, - marginRight: 5, - marginBottom: 5, - }} - data-testid="account-name" - > - {account && account.closed - ? 'Closed: ' + accountName - : accountName} - </View> - - {account && <NotesButton id={`account-${account.id}`} />} - <Button - bare - className="hover-visible" - onClick={() => onExposeName(true)} - > - <Pencil1 - style={{ - width: 11, - height: 11, - color: colors.n8, - }} - /> - </Button> - </View> - ) : ( - <View - style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }} - data-testid="account-name" - > - {account && account.closed - ? 'Closed: ' + accountName - : accountName} - </View> - )} - </View> - </View> - - <Balances - balanceQuery={balanceQuery} - showExtraBalances={showExtraBalances} - onToggleExtraBalances={onToggleExtraBalances} - account={account} - /> - - <Stack - spacing={2} - direction="row" - align="center" - style={{ marginTop: 12 }} - > - {((account && !account.closed) || canSync) && ( - <Button bare onClick={canSync ? onSync : onImport}> - {canSync ? ( - <> - <AnimatedRefresh - width={13} - height={13} - animating={ - (account && accountsSyncing === account.name) || - accountsSyncing === '__all' - } - style={{ color: 'currentColor', marginRight: 4 }} - />{' '} - Sync - </> - ) : ( - <> - <DownloadThickBottom - width={13} - height={13} - style={{ color: 'currentColor', marginRight: 4 }} - />{' '} - Import - </> - )} - </Button> - )} - {!showEmptyMessage && ( - <Button bare onClick={onAddTransaction}> - <Add - width={10} - height={10} - style={{ color: 'inherit', marginRight: 3 }} - />{' '} - Add New - </Button> - )} - <View> - <FilterButton onApply={onApplyFilter} /> - </View> - <InputWithContent - leftContent={ - <SearchAlternate - style={{ - width: 13, - height: 13, - flexShrink: 0, - color: search ? colors.p7 : 'inherit', - margin: 5, - marginRight: 0, - }} - /> - } - rightContent={ - search && ( - <Button - bare - style={{ padding: 8 }} - onClick={() => onSearch('')} - title="Clear search term" - > - <SvgRemove - style={{ - width: 8, - height: 8, - color: 'inherit', - }} - /> - </Button> - ) - } - inputRef={searchInput} - value={search} - placeholder="Search" - onKeyDown={e => { - if (e.key === 'Escape') onSearch(''); - }} - 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 ? ( - <View> - <Loading color={colors.n1} style={{ width: 16, height: 16 }} /> - </View> - ) : ( - <SelectedTransactionsButton - getTransaction={id => transactions.find(t => t.id === id)} - onShow={onShowTransactions} - onDuplicate={onBatchDuplicate} - onDelete={onBatchDelete} - onEdit={onBatchEdit} - onUnlink={onBatchUnlink} - onCreateRule={onCreateRule} - onScheduleAction={onScheduleAction} - /> - )} - <Button - bare - disabled={search !== '' || filters.length > 0} - style={{ padding: 6 }} - onClick={onToggleSplits} - title={ - splitsExpanded.state.mode === 'collapse' - ? 'Collapse split transactions' - : 'Expand split transactions' - } - > - {splitsExpanded.state.mode === 'collapse' ? ( - <ArrowsShrink3 - style={{ - width: 14, - height: 14, - color: 'inherit', - }} - /> - ) : ( - <ArrowsExpand3 - style={{ - width: 14, - height: 14, - color: 'inherit', - }} - /> - )} - </Button> - {account ? ( - <View> - <MenuButton onClick={() => setMenuOpen(true)} /> - - {menuOpen && ( - <AccountMenu - account={account} - canSync={canSync} - canShowBalances={canCalculateBalance()} - showBalances={showBalances} - showCleared={showCleared} - onMenuSelect={item => { - setMenuOpen(false); - onMenuSelect(item); - }} - onReconcile={onReconcile} - onClose={() => setMenuOpen(false)} - /> - )} - </View> - ) : ( - <View> - <MenuButton onClick={() => setMenuOpen(true)} /> - - {menuOpen && ( - <CategoryMenu - onMenuSelect={item => { - setMenuOpen(false); - onMenuSelect(item); - }} - onClose={() => setMenuOpen(false)} - /> - )} - </View> - )} - </Stack> - - {filters && filters.length > 0 && ( - <FiltersStack - filters={filters} - conditionsOp={conditionsOp} - onUpdateFilter={onUpdateFilter} - onDeleteFilter={onDeleteFilter} - onClearFilters={onClearFilters} - onReloadSavedFilter={onReloadSavedFilter} - filterId={filterId} - filtersList={filtersList} - onCondOpChange={onCondOpChange} - /> - )} - </View> - {reconcileAmount != null && ( - <ReconcilingMessage - targetBalance={reconcileAmount} - balanceQuery={balanceQuery} - onDone={onDoneReconciling} - onCreateTransaction={onCreateReconciliationTransaction} - /> - )} - </> - ); - }, -); - function AllTransactions({ account = {}, transactions, filtered, children }) { const { id: accountId } = account; let scheduleData = useCachedSchedules(); diff --git a/packages/desktop-client/src/components/accounts/Balance.js b/packages/desktop-client/src/components/accounts/Balance.js new file mode 100644 index 0000000000000000000000000000000000000000..186321ba5548760412018567e4c3bce491df81a8 --- /dev/null +++ b/packages/desktop-client/src/components/accounts/Balance.js @@ -0,0 +1,156 @@ +import React from 'react'; + +import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; +import q from 'loot-core/src/client/query-helpers'; + +import { useSelectedItems } from '../../hooks/useSelected'; +import ArrowButtonRight1 from '../../icons/v2/ArrowButtonRight1'; +import { colors } from '../../style'; +import { View, Text, Button } from '../common'; +import CellValue from '../spreadsheet/CellValue'; +import format from '../spreadsheet/format'; +import useSheetValue from '../spreadsheet/useSheetValue'; +import { isPreviewId } from '../transactions/TransactionsTable'; + +function DetailedBalance({ name, balance }) { + return ( + <Text + style={{ + marginLeft: 15, + backgroundColor: colors.n9, + borderRadius: 4, + padding: '4px 6px', + color: colors.n5, + }} + > + {name}{' '} + <Text style={{ fontWeight: 600 }}>{format(balance, 'financial')}</Text> + </Text> + ); +} + +function SelectedBalance({ selectedItems, account }) { + let name = `selected-balance-${[...selectedItems].join('-')}`; + + let rows = useSheetValue({ + name, + query: 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 balance = useSheetValue({ + name: name + '-sum', + query: q('transactions') + .filter({ id: { $oneof: finalIds } }) + .options({ splits: 'all' }) + .calculate({ $sum: '$amount' }), + }); + + let scheduleBalance = null; + let scheduleData = useCachedSchedules(); + let schedules = scheduleData ? scheduleData.schedules : []; + let previewIds = [...selectedItems] + .filter(id => isPreviewId(id)) + .map(id => id.slice(8)); + for (let s of schedules) { + if (previewIds.includes(s.id)) { + if (!account || account.id === s._account) { + scheduleBalance += s._amount; + } else { + scheduleBalance -= s._amount; + } + } + } + + if (balance == null) { + if (scheduleBalance == null) { + return null; + } else { + balance = scheduleBalance; + } + } else if (scheduleBalance != null) { + balance += scheduleBalance; + } + + return <DetailedBalance name="Selected balance:" balance={balance} />; +} + +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 ( + <View style={{ flexDirection: 'row' }}> + <DetailedBalance name="Cleared total:" balance={cleared} /> + <DetailedBalance name="Uncleared total:" balance={uncleared} /> + </View> + ); +} + +export function Balances({ + balanceQuery, + showExtraBalances, + onToggleExtraBalances, + account, +}) { + let selectedItems = useSelectedItems(); + + return ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginLeft: -5, + }} + > + <Button + data-testid="account-balance" + bare + onClick={onToggleExtraBalances} + style={{ + '& svg': { + opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0, + }, + '&:hover svg': { opacity: 1 }, + }} + > + <CellValue + binding={{ ...balanceQuery, value: 0 }} + type="financial" + style={{ fontSize: 22, fontWeight: 400 }} + getStyle={value => ({ + color: value < 0 ? colors.r5 : value > 0 ? colors.g5 : colors.n8, + })} + /> + + <ArrowButtonRight1 + style={{ + width: 10, + height: 10, + marginLeft: 10, + color: colors.n5, + transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)', + }} + /> + </Button> + {showExtraBalances && <MoreBalances balanceQuery={balanceQuery} />} + + {selectedItems.size > 0 && ( + <SelectedBalance selectedItems={selectedItems} account={account} /> + )} + </View> + ); +} diff --git a/packages/desktop-client/src/components/accounts/Header.js b/packages/desktop-client/src/components/accounts/Header.js new file mode 100644 index 0000000000000000000000000000000000000000..1a9033832c1cd3d47c9e0b60dbe80d3b07dca257 --- /dev/null +++ b/packages/desktop-client/src/components/accounts/Header.js @@ -0,0 +1,478 @@ +import React, { useState, useRef } from 'react'; + +import useSyncServerStatus from '../../hooks/useSyncServerStatus'; +import Loading from '../../icons/AnimatedLoading'; +import Add from '../../icons/v1/Add'; +import ArrowsExpand3 from '../../icons/v2/ArrowsExpand3'; +import ArrowsShrink3 from '../../icons/v2/ArrowsShrink3'; +import DownloadThickBottom from '../../icons/v2/DownloadThickBottom'; +import Pencil1 from '../../icons/v2/Pencil1'; +import SvgRemove from '../../icons/v2/Remove'; +import SearchAlternate from '../../icons/v2/SearchAlternate'; +import { styles, colors } from '../../style'; +import AnimatedRefresh from '../AnimatedRefresh'; +import { + View, + Button, + MenuButton, + MenuTooltip, + Input, + InputWithContent, + InitialFocus, + Menu, + Stack, +} from '../common'; +import { FilterButton } from '../filters/FiltersMenu'; +import { FiltersStack } from '../filters/SavedFilters'; +import { KeyHandlers } from '../KeyHandlers'; +import NotesButton from '../NotesButton'; +import { SelectedTransactionsButton } from '../transactions/SelectedTransactions'; +import { useSplitsExpanded } from '../transactions/TransactionsTable'; + +import { Balances } from './Balance'; +import { ReconcilingMessage, ReconcileTooltip } from './Reconcile'; + +export function AccountHeader({ + tableRef, + editingName, + isNameEditable, + workingHard, + accountName, + account, + filterId, + filtersList, + accountsSyncing, + accounts, + transactions, + showBalances, + showExtraBalances, + showCleared, + showEmptyMessage, + balanceQuery, + reconcileAmount, + canCalculateBalance, + search, + filters, + conditionsOp, + savePrefs, + onSearch, + onAddTransaction, + onShowTransactions, + onDoneReconciling, + onCreateReconciliationTransaction, + onToggleExtraBalances, + onSaveName, + onExposeName, + onSync, + onImport, + onMenuSelect, + onReconcile, + onBatchDelete, + onBatchDuplicate, + onBatchEdit, + onBatchUnlink, + onCreateRule, + onApplyFilter, + onUpdateFilter, + onClearFilters, + onReloadSavedFilter, + onCondOpChange, + onDeleteFilter, + onScheduleAction, +}) { + let [menuOpen, setMenuOpen] = useState(false); + let searchInput = useRef(null); + let splitsExpanded = useSplitsExpanded(); + + let canSync = 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 ( + <> + <KeyHandlers + keys={{ + 'ctrl+f, cmd+f': () => { + if (searchInput.current) { + searchInput.current.focus(); + } + }, + }} + /> + + <View style={[styles.pageContent, { paddingBottom: 10, flexShrink: 0 }]}> + <View style={{ marginTop: 2, alignItems: 'flex-start' }}> + <View> + {editingName ? ( + <InitialFocus> + <Input + defaultValue={accountName} + onEnter={e => onSaveName(e.target.value)} + onBlur={() => onExposeName(false)} + style={{ + fontSize: 25, + fontWeight: 500, + marginTop: -5, + marginBottom: -2, + marginLeft: -5, + }} + /> + </InitialFocus> + ) : isNameEditable ? ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 3, + '& .hover-visible': { + opacity: 0, + transition: 'opacity .25s', + }, + '&:hover .hover-visible': { + opacity: 1, + }, + }} + > + <View + style={{ + fontSize: 25, + fontWeight: 500, + marginRight: 5, + marginBottom: 5, + }} + data-testid="account-name" + > + {account && account.closed + ? 'Closed: ' + accountName + : accountName} + </View> + + {account && <NotesButton id={`account-${account.id}`} />} + <Button + bare + className="hover-visible" + onClick={() => onExposeName(true)} + > + <Pencil1 + style={{ + width: 11, + height: 11, + color: colors.n8, + }} + /> + </Button> + </View> + ) : ( + <View + style={{ fontSize: 25, fontWeight: 500, marginBottom: 5 }} + data-testid="account-name" + > + {account && account.closed + ? 'Closed: ' + accountName + : accountName} + </View> + )} + </View> + </View> + + <Balances + balanceQuery={balanceQuery} + showExtraBalances={showExtraBalances} + onToggleExtraBalances={onToggleExtraBalances} + account={account} + /> + + <Stack + spacing={2} + direction="row" + align="center" + style={{ marginTop: 12 }} + > + {((account && !account.closed) || canSync) && ( + <Button bare onClick={canSync ? onSync : onImport}> + {canSync ? ( + <> + <AnimatedRefresh + width={13} + height={13} + animating={ + (account && accountsSyncing === account.name) || + accountsSyncing === '__all' + } + style={{ color: 'currentColor', marginRight: 4 }} + />{' '} + Sync + </> + ) : ( + <> + <DownloadThickBottom + width={13} + height={13} + style={{ color: 'currentColor', marginRight: 4 }} + />{' '} + Import + </> + )} + </Button> + )} + {!showEmptyMessage && ( + <Button bare onClick={onAddTransaction}> + <Add + width={10} + height={10} + style={{ color: 'inherit', marginRight: 3 }} + />{' '} + Add New + </Button> + )} + <View> + <FilterButton onApply={onApplyFilter} /> + </View> + <InputWithContent + leftContent={ + <SearchAlternate + style={{ + width: 13, + height: 13, + flexShrink: 0, + color: search ? colors.p7 : 'inherit', + margin: 5, + marginRight: 0, + }} + /> + } + rightContent={ + search && ( + <Button + bare + style={{ padding: 8 }} + onClick={() => onSearch('')} + title="Clear search term" + > + <SvgRemove + style={{ + width: 8, + height: 8, + color: 'inherit', + }} + /> + </Button> + ) + } + inputRef={searchInput} + value={search} + placeholder="Search" + onKeyDown={e => { + if (e.key === 'Escape') onSearch(''); + }} + 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 ? ( + <View> + <Loading color={colors.n1} style={{ width: 16, height: 16 }} /> + </View> + ) : ( + <SelectedTransactionsButton + getTransaction={id => transactions.find(t => t.id === id)} + onShow={onShowTransactions} + onDuplicate={onBatchDuplicate} + onDelete={onBatchDelete} + onEdit={onBatchEdit} + onUnlink={onBatchUnlink} + onCreateRule={onCreateRule} + onScheduleAction={onScheduleAction} + /> + )} + <Button + bare + disabled={search !== '' || filters.length > 0} + style={{ padding: 6 }} + onClick={onToggleSplits} + title={ + splitsExpanded.state.mode === 'collapse' + ? 'Collapse split transactions' + : 'Expand split transactions' + } + > + {splitsExpanded.state.mode === 'collapse' ? ( + <ArrowsShrink3 + style={{ + width: 14, + height: 14, + color: 'inherit', + }} + /> + ) : ( + <ArrowsExpand3 + style={{ + width: 14, + height: 14, + color: 'inherit', + }} + /> + )} + </Button> + {account ? ( + <View> + <MenuButton onClick={() => setMenuOpen(true)} /> + + {menuOpen && ( + <AccountMenu + account={account} + canSync={canSync} + canShowBalances={canCalculateBalance()} + showBalances={showBalances} + showCleared={showCleared} + onMenuSelect={item => { + setMenuOpen(false); + onMenuSelect(item); + }} + onReconcile={onReconcile} + onClose={() => setMenuOpen(false)} + /> + )} + </View> + ) : ( + <View> + <MenuButton onClick={() => setMenuOpen(true)} /> + + {menuOpen && ( + <CategoryMenu + onMenuSelect={item => { + setMenuOpen(false); + onMenuSelect(item); + }} + onClose={() => setMenuOpen(false)} + /> + )} + </View> + )} + </Stack> + + {filters && filters.length > 0 && ( + <FiltersStack + filters={filters} + conditionsOp={conditionsOp} + onUpdateFilter={onUpdateFilter} + onDeleteFilter={onDeleteFilter} + onClearFilters={onClearFilters} + onReloadSavedFilter={onReloadSavedFilter} + filterId={filterId} + filtersList={filtersList} + onCondOpChange={onCondOpChange} + /> + )} + </View> + {reconcileAmount != null && ( + <ReconcilingMessage + targetBalance={reconcileAmount} + balanceQuery={balanceQuery} + onDone={onDoneReconciling} + onCreateTransaction={onCreateReconciliationTransaction} + /> + )} + </> + ); +} + +function AccountMenu({ + account, + canSync, + showBalances, + canShowBalances, + showCleared, + onClose, + onReconcile, + onMenuSelect, +}) { + let [tooltip, setTooltip] = useState('default'); + const syncServerStatus = useSyncServerStatus(); + + return tooltip === 'reconcile' ? ( + <ReconcileTooltip + account={account} + onClose={onClose} + onReconcile={onReconcile} + /> + ) : ( + <MenuTooltip width={200} onClose={onClose}> + <Menu + onMenuSelect={item => { + if (item === 'reconcile') { + setTooltip('reconcile'); + } else { + onMenuSelect(item); + } + }} + items={[ + canShowBalances && { + name: 'toggle-balance', + text: (showBalances ? 'Hide' : 'Show') + ' Running Balance', + }, + { + name: 'toggle-cleared', + text: (showCleared ? 'Hide' : 'Show') + ' “Cleared†Checkboxes', + }, + { name: 'export', text: 'Export' }, + { name: 'reconcile', text: 'Reconcile' }, + account && + !account.closed && + (canSync + ? { + name: 'unlink', + text: 'Unlink Account', + } + : syncServerStatus === 'online' && { + name: 'link', + text: 'Link Account', + }), + account.closed + ? { name: 'reopen', text: 'Reopen Account' } + : { name: 'close', text: 'Close Account' }, + ].filter(x => x)} + /> + </MenuTooltip> + ); +} + +function CategoryMenu({ onClose, onMenuSelect }) { + return ( + <MenuTooltip width={200} onClose={onClose}> + <Menu + onMenuSelect={item => { + onMenuSelect(item); + }} + items={[{ name: 'export', text: 'Export' }]} + /> + </MenuTooltip> + ); +} diff --git a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js index 49c99b0c713241b2930f1d5f04a6cb14f0ad1feb..3c6da2a63ee279d643f82814b091486c8975e1ea 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccountDetails.js +++ b/packages/desktop-client/src/components/accounts/MobileAccountDetails.js @@ -8,8 +8,7 @@ import { colors, styles } from '../../style'; import { Button, InputWithContent, Label, View } from '../common'; import Text from '../common/Text'; import CellValue from '../spreadsheet/CellValue'; - -import { TransactionList } from './MobileTransaction'; +import { TransactionList } from '../transactions/MobileTransaction'; function TransactionSearchInput({ accountName, onSearch }) { const [text, setText] = useState(''); diff --git a/packages/desktop-client/src/components/accounts/Reconcile.js b/packages/desktop-client/src/components/accounts/Reconcile.js new file mode 100644 index 0000000000000000000000000000000000000000..b542d9520ec7e677af1639498deb3be9da04e399 --- /dev/null +++ b/packages/desktop-client/src/components/accounts/Reconcile.js @@ -0,0 +1,128 @@ +import React from 'react'; + +import * as queries from 'loot-core/src/client/queries'; +import { currencyToInteger } from 'loot-core/src/shared/util'; + +import CheckCircle1 from '../../icons/v2/CheckCircle1'; +import { styles, colors } from '../../style'; +import { View, Text, Button, Input, InitialFocus, Tooltip } from '../common'; +import format from '../spreadsheet/format'; +import useSheetValue from '../spreadsheet/useSheetValue'; + +export function ReconcilingMessage({ + balanceQuery, + targetBalance, + onDone, + onCreateTransaction, +}) { + let cleared = useSheetValue({ + name: balanceQuery.name + '-cleared', + value: 0, + query: balanceQuery.query.filter({ cleared: true }), + }); + let targetDiff = targetBalance - cleared; + + return ( + <View + style={{ + flexDirection: 'row', + alignSelf: 'center', + backgroundColor: 'white', + ...styles.shadow, + borderRadius: 4, + marginTop: 5, + marginBottom: 15, + padding: 10, + }} + > + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + {targetDiff === 0 ? ( + <View + style={{ + color: colors.g4, + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }} + > + <CheckCircle1 + style={{ + width: 13, + height: 13, + color: colors.g5, + marginRight: 3, + }} + /> + All reconciled! + </View> + ) : ( + <View style={{ color: colors.n3 }}> + <Text style={{ fontStyle: 'italic', textAlign: 'center' }}> + Your cleared balance{' '} + <strong>{format(cleared, 'financial')}</strong> needs{' '} + <strong> + {(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')} + </strong>{' '} + to match + <br /> your bank’s balance of{' '} + <Text style={{ fontWeight: 700 }}> + {format(targetBalance, 'financial')} + </Text> + </Text> + </View> + )} + <View style={{ marginLeft: 15 }}> + <Button primary onClick={onDone}> + Done Reconciling + </Button> + </View> + {targetDiff !== 0 && ( + <View style={{ marginLeft: 15 }}> + <Button onClick={() => onCreateTransaction(targetDiff)}> + Create Reconciliation Transaction + </Button> + </View> + )} + </View> + </View> + ); +} + +export function ReconcileTooltip({ account, onReconcile, onClose }) { + let balance = useSheetValue(queries.accountBalance(account)); + + function onSubmit(e) { + e.preventDefault(); + let input = e.target.elements[0]; + let amount = currencyToInteger(input.value); + if (amount != null) { + onReconcile(amount == null ? balance : amount); + onClose(); + } else { + input.select(); + } + } + + return ( + <Tooltip position="bottom-right" width={275} onClose={onClose}> + <View style={{ padding: '5px 8px' }}> + <Text> + Enter the current balance of your bank account that you want to + reconcile with: + </Text> + <form onSubmit={onSubmit}> + {balance != null && ( + <InitialFocus> + <Input + defaultValue={format(balance, 'financial')} + style={{ margin: '7px 0' }} + /> + </InitialFocus> + )} + <Button primary>Reconcile</Button> + </form> + </View> + </Tooltip> + ); +} diff --git a/packages/desktop-client/src/components/common.tsx b/packages/desktop-client/src/components/common.tsx index d63d8233195f1c9487c3a211eb22228aedc13fa7..0b24fb651c2efe0e00b7181300dad75de34b3d83 100644 --- a/packages/desktop-client/src/components/common.tsx +++ b/packages/desktop-client/src/components/common.tsx @@ -28,6 +28,8 @@ export { default as Input } from './common/Input'; export { default as InputWithContent } from './common/InputWithContent'; export { default as Label } from './common/Label'; export { default as Menu } from './common/Menu'; +export { default as MenuButton } from './common/MenuButton'; +export { default as MenuTooltip } from './common/MenuTooltip'; export { default as Modal, ModalButtons } from './common/Modal'; export { default as Search } from './common/Search'; export { default as Select } from './common/Select'; diff --git a/packages/desktop-client/src/components/common/MenuButton.tsx b/packages/desktop-client/src/components/common/MenuButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bda660c790f71de085b888896124df36ad70681f --- /dev/null +++ b/packages/desktop-client/src/components/common/MenuButton.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple'; + +import Button from './Button'; + +export default function MenuButton({ onClick }) { + return ( + <Button bare onClick={onClick} aria-label="Menu"> + <DotsHorizontalTriple + width={15} + height={15} + style={{ color: 'inherit', transform: 'rotateZ(90deg)' }} + /> + </Button> + ); +} diff --git a/packages/desktop-client/src/components/common/MenuTooltip.tsx b/packages/desktop-client/src/components/common/MenuTooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7549da2ceb386ed0adaf5b3a15b28f7c5e1000e9 --- /dev/null +++ b/packages/desktop-client/src/components/common/MenuTooltip.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Tooltip } from '../common'; + +export default function MenuTooltip({ width, onClose, children }) { + return ( + <Tooltip + position="bottom-right" + width={width} + style={{ padding: 0 }} + onClose={onClose} + > + {children} + </Tooltip> + ); +} diff --git a/packages/desktop-client/src/components/filters/SavedFilters.js b/packages/desktop-client/src/components/filters/SavedFilters.js index a243cf8f4b3f6bf2c305b8de0701f3a920a5208c..eff68869058220053ddb95e46bdc203abb5ec170 100644 --- a/packages/desktop-client/src/components/filters/SavedFilters.js +++ b/packages/desktop-client/src/components/filters/SavedFilters.js @@ -4,8 +4,7 @@ import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import ExpandArrow from '../../icons/v0/ExpandArrow'; import { colors } from '../../style'; -import { MenuTooltip } from '../accounts/Account'; -import { View, Text, Button, Menu, Stack } from '../common'; +import { View, Text, Button, Menu, MenuTooltip, Stack } from '../common'; import { FormField, FormLabel } from '../forms'; import { FieldSelect } from '../modals/EditRule'; import GenericInput from '../util/GenericInput'; diff --git a/packages/desktop-client/src/components/modals/EditRule.js b/packages/desktop-client/src/components/modals/EditRule.js index bfb2594218b76a0ef07ee34a55cb4a9b1910dce7..f949e99f3e9191baf8cb69891aca44dad7427175 100644 --- a/packages/desktop-client/src/components/modals/EditRule.js +++ b/packages/desktop-client/src/components/modals/EditRule.js @@ -30,7 +30,6 @@ import AddIcon from '../../icons/v0/Add'; import SubtractIcon from '../../icons/v0/Subtract'; import InformationOutline from '../../icons/v1/InformationOutline'; import { colors } from '../../style'; -import SimpleTransactionsTable from '../accounts/SimpleTransactionsTable'; import { View, Text, @@ -41,6 +40,7 @@ import { Tooltip, } from '../common'; import { StatusBadge } from '../schedules/StatusBadge'; +import SimpleTransactionsTable from '../transactions/SimpleTransactionsTable'; import { BetweenAmountInput } from '../util/AmountInput'; import DisplayId from '../util/DisplayId'; import GenericInput from '../util/GenericInput'; diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.js b/packages/desktop-client/src/components/schedules/EditSchedule.js index 32bd37231d9bccc06fb854cf587a0a9455872f45..8a9176e976f2d37e6bf1341f58e785e41def3439 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.js +++ b/packages/desktop-client/src/components/schedules/EditSchedule.js @@ -11,7 +11,6 @@ import { extractScheduleConds } from 'loot-core/src/shared/schedules'; import useSelected, { SelectedProvider } from '../../hooks/useSelected'; import { colors } from '../../style'; -import SimpleTransactionsTable from '../accounts/SimpleTransactionsTable'; import AccountAutocomplete from '../autocomplete/AccountAutocomplete'; import PayeeAutocomplete from '../autocomplete/PayeeAutocomplete'; import { Stack, View, Text, Button } from '../common'; @@ -21,6 +20,7 @@ import { Page } from '../Page'; import DateSelect from '../select/DateSelect'; import RecurringSchedulePicker from '../select/RecurringSchedulePicker'; import { SelectedItemsButton } from '../table'; +import SimpleTransactionsTable from '../transactions/SimpleTransactionsTable'; import { AmountInput, BetweenAmountInput } from '../util/AmountInput'; import GenericInput from '../util/GenericInput'; diff --git a/packages/desktop-client/src/components/accounts/MobileTransaction.js b/packages/desktop-client/src/components/transactions/MobileTransaction.js similarity index 100% rename from packages/desktop-client/src/components/accounts/MobileTransaction.js rename to packages/desktop-client/src/components/transactions/MobileTransaction.js diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.js b/packages/desktop-client/src/components/transactions/SelectedTransactions.js new file mode 100644 index 0000000000000000000000000000000000000000..85195b29c925d49b836df9473c05dc05e430a8c5 --- /dev/null +++ b/packages/desktop-client/src/components/transactions/SelectedTransactions.js @@ -0,0 +1,153 @@ +import React, { useMemo } from 'react'; + +import { useSelectedItems } from '../../hooks/useSelected'; +import { usePushModal } from '../../util/router-tools'; +import { Menu } from '../common'; +import { SelectedItemsButton } from '../table'; + +import { isPreviewId } from './TransactionsTable'; + +export function SelectedTransactionsButton({ + getTransaction, + onShow, + onDuplicate, + onDelete, + onEdit, + onUnlink, + onCreateRule, + onScheduleAction, +}) { + let pushModal = usePushModal(); + let selectedItems = useSelectedItems(); + + let types = useMemo(() => { + let items = [...selectedItems]; + return { + preview: !!items.find(id => isPreviewId(id)), + trans: !!items.find(id => !isPreviewId(id)), + }; + }, [selectedItems]); + + let ambiguousDuplication = useMemo(() => { + let transactions = [...selectedItems].map(id => getTransaction(id)); + + return transactions.some(t => t && t.is_child); + }, [selectedItems]); + + let linked = useMemo(() => { + return ( + !types.preview && + [...selectedItems].every(id => { + let t = getTransaction(id); + return t && t.schedule; + }) + ); + }, [types.preview, selectedItems, getTransaction]); + + return ( + <SelectedItemsButton + name="transactions" + keyHandlers={ + types.trans && { + f: () => 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: 'duplicate', + text: 'Duplicate', + disabled: ambiguousDuplication, + }, + { 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', + }, + { + name: 'create-rule', + text: 'Create rule', + }, + ]), + 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 'duplicate': + onDuplicate([...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) { + pushModal(`/schedule/edit/${scheduleId}`); + } + break; + case 'link-schedule': + pushModal('/schedule/link', { + transactionIds: [...selectedItems], + }); + break; + case 'unlink-schedule': + onUnlink([...selectedItems]); + break; + case 'create-rule': + onCreateRule([...selectedItems]); + break; + default: + onEdit(name, [...selectedItems]); + } + }} + ></SelectedItemsButton> + ); +} diff --git a/packages/desktop-client/src/components/accounts/SimpleTransactionsTable.js b/packages/desktop-client/src/components/transactions/SimpleTransactionsTable.js similarity index 100% rename from packages/desktop-client/src/components/accounts/SimpleTransactionsTable.js rename to packages/desktop-client/src/components/transactions/SimpleTransactionsTable.js diff --git a/packages/desktop-client/src/components/accounts/TransactionList.js b/packages/desktop-client/src/components/transactions/TransactionList.js similarity index 100% rename from packages/desktop-client/src/components/accounts/TransactionList.js rename to packages/desktop-client/src/components/transactions/TransactionList.js diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.js b/packages/desktop-client/src/components/transactions/TransactionsTable.js similarity index 100% rename from packages/desktop-client/src/components/accounts/TransactionsTable.js rename to packages/desktop-client/src/components/transactions/TransactionsTable.js diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.test.js b/packages/desktop-client/src/components/transactions/TransactionsTable.test.js similarity index 100% rename from packages/desktop-client/src/components/accounts/TransactionsTable.test.js rename to packages/desktop-client/src/components/transactions/TransactionsTable.test.js diff --git a/upcoming-release-notes/1258.md b/upcoming-release-notes/1258.md new file mode 100644 index 0000000000000000000000000000000000000000..a33a944d149c497e167b7e708b4be8a86f8c424b --- /dev/null +++ b/upcoming-release-notes/1258.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [carkom] +--- + +Reorganized accounts directory. Pulled our Header functions to make the accounts.js smaller and more manageable. \ No newline at end of file