mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 18:40:34 -05:00
Reorganize Accounts Folder (#1258)
There's no new code here, just a reorg. Had to change some import calls in some files to make it all work properly. Having just done a lot of work in the accounts directory I figured it could do with some organization. I've broken out all the Header functions into a different file in order to cut down on the size of the account.js file. I also moved the transactions files into a new directory since they are used by other pages. Also makes them easier to find with this structure.
This commit is contained in:
@@ -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();
|
||||
|
||||
156
packages/desktop-client/src/components/accounts/Balance.js
Normal file
156
packages/desktop-client/src/components/accounts/Balance.js
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
478
packages/desktop-client/src/components/accounts/Header.js
Normal file
478
packages/desktop-client/src/components/accounts/Header.js
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('');
|
||||
|
||||
128
packages/desktop-client/src/components/accounts/Reconcile.js
Normal file
128
packages/desktop-client/src/components/accounts/Reconcile.js
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
17
packages/desktop-client/src/components/common/MenuButton.tsx
Normal file
17
packages/desktop-client/src/components/common/MenuButton.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
6
upcoming-release-notes/1258.md
Normal file
6
upcoming-release-notes/1258.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [carkom]
|
||||
---
|
||||
|
||||
Reorganized accounts directory. Pulled our Header functions to make the accounts.js smaller and more manageable.
|
||||
Reference in New Issue
Block a user