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:
Neil
2023-07-04 13:40:58 +01:00
committed by GitHub
parent e4ec5b3eb1
commit 050f48ac2a
18 changed files with 968 additions and 940 deletions

View File

@@ -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 banks 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();

View 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>
);
}

View 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>
);
}

View File

@@ -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('');

View 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 banks 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>
);
}

View File

@@ -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';

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);
}

View 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.