mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-29 19:14:22 -05:00
649 lines
20 KiB
TypeScript
649 lines
20 KiB
TypeScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
} from 'react';
|
|
import {
|
|
ListBox,
|
|
ListBoxSection,
|
|
Header,
|
|
Collection,
|
|
} from 'react-aria-components';
|
|
import { Trans, useTranslation } from 'react-i18next';
|
|
|
|
import { Button } from '@actual-app/components/button';
|
|
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
|
import { SvgDelete } from '@actual-app/components/icons/v0';
|
|
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
|
import {
|
|
Menu,
|
|
type MenuItem,
|
|
type MenuItemObject,
|
|
} from '@actual-app/components/menu';
|
|
import { Popover } from '@actual-app/components/popover';
|
|
import { styles } from '@actual-app/components/styles';
|
|
import { Text } from '@actual-app/components/text';
|
|
import { theme } from '@actual-app/components/theme';
|
|
import { View } from '@actual-app/components/view';
|
|
|
|
import * as monthUtils from 'loot-core/shared/months';
|
|
import { isPreviewId } from 'loot-core/shared/transactions';
|
|
import { validForTransfer } from 'loot-core/shared/transfer';
|
|
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
|
import { type TransactionEntity } from 'loot-core/types/models';
|
|
|
|
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
|
|
|
|
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
|
|
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
|
import { useLocale } from '@desktop-client/hooks/useLocale';
|
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
|
import { usePayees } from '@desktop-client/hooks/usePayees';
|
|
import {
|
|
useSelectedDispatch,
|
|
useSelectedItems,
|
|
} from '@desktop-client/hooks/useSelected';
|
|
import { useTransactionBatchActions } from '@desktop-client/hooks/useTransactionBatchActions';
|
|
import { useUndo } from '@desktop-client/hooks/useUndo';
|
|
import { setNotificationInset } from '@desktop-client/notifications/notificationsSlice';
|
|
import { useDispatch } from '@desktop-client/redux';
|
|
|
|
const NOTIFICATION_BOTTOM_INSET = 75;
|
|
|
|
type LoadingProps = {
|
|
style?: CSSProperties;
|
|
'aria-label': string;
|
|
};
|
|
|
|
function Loading({ style, 'aria-label': ariaLabel }: LoadingProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<View
|
|
aria-label={ariaLabel || t('Loading...')}
|
|
style={{
|
|
backgroundColor: theme.mobilePageBackground,
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
...style,
|
|
}}
|
|
>
|
|
<AnimatedLoading width={25} height={25} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type TransactionListProps = {
|
|
isLoading: boolean;
|
|
transactions: readonly TransactionEntity[];
|
|
onOpenTransaction?: (transaction: TransactionEntity) => void;
|
|
isLoadingMore: boolean;
|
|
onLoadMore: () => void;
|
|
showMakeTransfer?: boolean;
|
|
};
|
|
|
|
export function TransactionList({
|
|
isLoading,
|
|
transactions,
|
|
onOpenTransaction,
|
|
isLoadingMore,
|
|
onLoadMore,
|
|
showMakeTransfer = false,
|
|
}: TransactionListProps) {
|
|
const locale = useLocale();
|
|
const { t } = useTranslation();
|
|
const sections = useMemo(() => {
|
|
// Group by date. We can assume transactions is ordered
|
|
const sections: {
|
|
id: string;
|
|
date: TransactionEntity['date'];
|
|
transactions: TransactionEntity[];
|
|
}[] = [];
|
|
transactions.forEach(transaction => {
|
|
if (
|
|
sections.length === 0 ||
|
|
transaction.date !== sections[sections.length - 1].date
|
|
) {
|
|
sections.push({
|
|
id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${transaction.date}`,
|
|
date: transaction.date,
|
|
transactions: [],
|
|
});
|
|
}
|
|
|
|
sections[sections.length - 1].transactions.push(transaction);
|
|
});
|
|
return sections;
|
|
}, [transactions]);
|
|
|
|
const dispatchSelected = useSelectedDispatch();
|
|
const selectedTransactions = useSelectedItems();
|
|
|
|
const onTransactionPress: (
|
|
transaction: TransactionEntity,
|
|
isLongPress?: boolean,
|
|
) => void = useCallback(
|
|
(transaction, isLongPress = false) => {
|
|
const isPreview = isPreviewId(transaction.id);
|
|
if (!isPreview && (isLongPress || selectedTransactions.size > 0)) {
|
|
dispatchSelected({ type: 'select', id: transaction.id });
|
|
} else {
|
|
onOpenTransaction?.(transaction);
|
|
}
|
|
},
|
|
[dispatchSelected, onOpenTransaction, selectedTransactions],
|
|
);
|
|
|
|
useScrollListener(
|
|
useCallback(
|
|
({ hasScrolledToEnd }) => {
|
|
if (hasScrolledToEnd('down', 100)) {
|
|
onLoadMore?.();
|
|
}
|
|
},
|
|
[onLoadMore],
|
|
),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isLoading && (
|
|
<Loading
|
|
style={{ paddingBottom: 8 }}
|
|
aria-label={t('Loading transactions...')}
|
|
/>
|
|
)}
|
|
<ListBox
|
|
aria-label={t('Transaction list')}
|
|
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
|
|
selectedKeys={selectedTransactions}
|
|
dependencies={[selectedTransactions]}
|
|
renderEmptyState={() =>
|
|
!isLoading && (
|
|
<View
|
|
style={{
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: theme.mobilePageBackground,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 15 }}>
|
|
<Trans>No transactions</Trans>
|
|
</Text>
|
|
</View>
|
|
)
|
|
}
|
|
items={sections}
|
|
>
|
|
{section => (
|
|
<ListBoxSection>
|
|
<Header
|
|
style={{
|
|
...styles.smallText,
|
|
backgroundColor: theme.pageBackground,
|
|
color: theme.tableHeaderText,
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
paddingBottom: 4,
|
|
paddingTop: 4,
|
|
position: 'sticky',
|
|
top: '0',
|
|
width: '100%',
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
{monthUtils.format(section.date, 'MMMM dd, yyyy', locale)}
|
|
</Header>
|
|
<Collection
|
|
items={section.transactions.filter(
|
|
t => !isPreviewId(t.id) || !t.is_child,
|
|
)}
|
|
addIdAndValue
|
|
>
|
|
{transaction => (
|
|
<TransactionListItem
|
|
key={transaction.id}
|
|
value={transaction}
|
|
onPress={trans => onTransactionPress(trans)}
|
|
onLongPress={trans => onTransactionPress(trans, true)}
|
|
/>
|
|
)}
|
|
</Collection>
|
|
</ListBoxSection>
|
|
)}
|
|
</ListBox>
|
|
|
|
{isLoadingMore && (
|
|
<Loading
|
|
aria-label={t('Loading more transactions...')}
|
|
style={{
|
|
// Same height as transaction list item
|
|
height: ROW_HEIGHT,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{selectedTransactions.size > 0 && (
|
|
<SelectedTransactionsFloatingActionBar
|
|
transactions={transactions}
|
|
showMakeTransfer={showMakeTransfer}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
type SelectedTransactionsFloatingActionBarProps = {
|
|
transactions: readonly TransactionEntity[];
|
|
style?: CSSProperties;
|
|
showMakeTransfer: boolean;
|
|
};
|
|
|
|
function SelectedTransactionsFloatingActionBar({
|
|
transactions,
|
|
style = {},
|
|
showMakeTransfer,
|
|
}: SelectedTransactionsFloatingActionBarProps) {
|
|
const { t } = useTranslation();
|
|
const editMenuTriggerRef = useRef(null);
|
|
const [isEditMenuOpen, setIsEditMenuOpen] = useState(false);
|
|
const moreOptionsMenuTriggerRef = useRef(null);
|
|
const [isMoreOptionsMenuOpen, setIsMoreOptionsMenuOpen] = useState(false);
|
|
const getMenuItemStyle = useCallback(
|
|
<T extends string>(item: MenuItemObject<T>) => ({
|
|
...styles.mobileMenuItem,
|
|
color: theme.mobileHeaderText,
|
|
...(item.disabled === true && { color: theme.buttonBareDisabledText }),
|
|
...(item.name === 'delete' && { color: theme.errorTextMenu }),
|
|
}),
|
|
[],
|
|
);
|
|
const selectedTransactions = useSelectedItems();
|
|
const selectedTransactionsArray = Array.from(selectedTransactions);
|
|
const dispatchSelected = useSelectedDispatch();
|
|
|
|
const buttonProps = useMemo(
|
|
() => ({
|
|
style: {
|
|
...styles.mobileMenuItem,
|
|
color: 'currentColor',
|
|
height: styles.mobileMinHeight,
|
|
},
|
|
activeStyle: {
|
|
color: 'currentColor',
|
|
},
|
|
hoveredStyle: {
|
|
color: 'currentColor',
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const allTransactionsAreLinked = useMemo(() => {
|
|
return transactions
|
|
.filter(t => selectedTransactions.has(t.id))
|
|
.every(t => t.schedule);
|
|
}, [transactions, selectedTransactions]);
|
|
|
|
const isMoreThanOne = selectedTransactions.size > 1;
|
|
|
|
const { showUndoNotification } = useUndo();
|
|
const {
|
|
onBatchEdit,
|
|
onBatchDuplicate,
|
|
onBatchDelete,
|
|
onBatchLinkSchedule,
|
|
onBatchUnlinkSchedule,
|
|
onSetTransfer,
|
|
onMerge,
|
|
} = useTransactionBatchActions();
|
|
|
|
const navigate = useNavigate();
|
|
const accounts = useAccounts();
|
|
const accountsById = useMemo(() => groupById(accounts), [accounts]);
|
|
|
|
const payees = usePayees();
|
|
const payeesById = useMemo(() => groupById(payees), [payees]);
|
|
|
|
const { list: categories } = useCategories();
|
|
const categoriesById = useMemo(() => groupById(categories), [categories]);
|
|
|
|
const dispatch = useDispatch();
|
|
useEffect(() => {
|
|
dispatch(
|
|
setNotificationInset({ inset: { bottom: NOTIFICATION_BOTTOM_INSET } }),
|
|
);
|
|
return () => {
|
|
dispatch(setNotificationInset(null));
|
|
};
|
|
}, [dispatch]);
|
|
|
|
const twoTransactions: [TransactionEntity, TransactionEntity] | undefined =
|
|
useMemo(() => {
|
|
// only two selected
|
|
if (selectedTransactionsArray.length !== 2) {
|
|
return undefined;
|
|
}
|
|
|
|
const [a, b] = selectedTransactionsArray.map(id =>
|
|
transactions.find(t => t.id === id),
|
|
);
|
|
if (!a || !b) {
|
|
return undefined;
|
|
}
|
|
|
|
return [a, b];
|
|
}, [selectedTransactionsArray, transactions]);
|
|
|
|
const canBeTransfer = useMemo(() => {
|
|
if (!twoTransactions) {
|
|
return false;
|
|
}
|
|
const [fromTrans, toTrans] = twoTransactions;
|
|
return validForTransfer(fromTrans, toTrans);
|
|
}, [twoTransactions]);
|
|
|
|
const canMerge = useMemo(() => {
|
|
return Boolean(
|
|
twoTransactions &&
|
|
twoTransactions[0].amount === twoTransactions[1].amount,
|
|
);
|
|
}, [twoTransactions]);
|
|
|
|
const moreOptionsMenuItems: MenuItem<string>[] = [
|
|
{
|
|
name: 'duplicate',
|
|
text: t('Duplicate'),
|
|
},
|
|
{
|
|
name: allTransactionsAreLinked ? 'unlink-schedule' : 'link-schedule',
|
|
text: allTransactionsAreLinked
|
|
? t('Unlink schedule')
|
|
: t('Link schedule'),
|
|
},
|
|
{
|
|
name: 'delete',
|
|
text: t('Delete'),
|
|
},
|
|
{
|
|
name: 'merge',
|
|
text: t('Merge'),
|
|
disabled: !canMerge,
|
|
},
|
|
];
|
|
|
|
if (showMakeTransfer) {
|
|
moreOptionsMenuItems.splice(2, 0, {
|
|
name: 'transfer',
|
|
text: t('Make transfer'),
|
|
disabled: !canBeTransfer,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<FloatingActionBar style={style}>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
padding: 8,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-start',
|
|
}}
|
|
>
|
|
<Button
|
|
variant="bare"
|
|
{...buttonProps}
|
|
style={{ ...buttonProps.style, marginRight: 4 }}
|
|
onPress={() => {
|
|
if (selectedTransactions.size > 0) {
|
|
dispatchSelected({ type: 'select-none' });
|
|
}
|
|
}}
|
|
>
|
|
<SvgDelete width={10} height={10} />
|
|
</Button>
|
|
<Text style={styles.mediumText}>
|
|
{selectedTransactions.size}{' '}
|
|
{isMoreThanOne ? 'transactions' : 'transaction'} selected
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<Button
|
|
variant="bare"
|
|
ref={editMenuTriggerRef}
|
|
aria-label={t('Edit fields')}
|
|
onPress={() => {
|
|
setIsEditMenuOpen(true);
|
|
}}
|
|
{...buttonProps}
|
|
>
|
|
<Trans>Edit</Trans>
|
|
</Button>
|
|
|
|
<Popover
|
|
triggerRef={editMenuTriggerRef}
|
|
isOpen={isEditMenuOpen}
|
|
onOpenChange={() => setIsEditMenuOpen(false)}
|
|
style={{ width: 200 }}
|
|
>
|
|
<Menu
|
|
getItemStyle={getMenuItemStyle}
|
|
style={{ backgroundColor: theme.floatingActionBarBackground }}
|
|
onMenuSelect={name => {
|
|
onBatchEdit?.({
|
|
name,
|
|
ids: selectedTransactionsArray,
|
|
onSuccess: (ids, name, value, mode) => {
|
|
let displayValue;
|
|
switch (name) {
|
|
case 'account':
|
|
displayValue =
|
|
accountsById[String(value)]?.name ?? value;
|
|
break;
|
|
case 'category':
|
|
displayValue =
|
|
categoriesById[String(value)]?.name ?? value;
|
|
break;
|
|
case 'payee':
|
|
displayValue = payeesById[String(value)]?.name ?? value;
|
|
break;
|
|
case 'amount':
|
|
displayValue = Number.isNaN(Number(value))
|
|
? value
|
|
: integerToCurrency(Number(value));
|
|
break;
|
|
case 'notes':
|
|
displayValue = `${mode} with ${value}`;
|
|
break;
|
|
default:
|
|
displayValue = value;
|
|
break;
|
|
}
|
|
|
|
showUndoNotification({
|
|
message: `Successfully updated ${name} of ${ids.length} transaction${ids.length > 1 ? 's' : ''} to [${displayValue}](#${displayValue}).`,
|
|
messageActions: {
|
|
[String(displayValue)]: () => {
|
|
switch (name) {
|
|
case 'account':
|
|
navigate(`/accounts/${value}`);
|
|
break;
|
|
case 'category':
|
|
navigate(`/categories/${value}`);
|
|
break;
|
|
case 'payee':
|
|
navigate(`/payees`);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
},
|
|
});
|
|
setIsEditMenuOpen(false);
|
|
}}
|
|
items={[
|
|
// Add support later on.
|
|
// Pikaday doesn't play well will mobile.
|
|
// We should consider switching to react-aria date picker.
|
|
// {
|
|
// name: 'date',
|
|
// text: 'Date',
|
|
// },
|
|
{
|
|
name: 'account',
|
|
text: t('Account'),
|
|
},
|
|
{
|
|
name: 'payee',
|
|
text: t('Payee'),
|
|
},
|
|
{
|
|
name: 'notes',
|
|
text: t('Notes'),
|
|
},
|
|
{
|
|
name: 'category',
|
|
text: t('Category'),
|
|
},
|
|
// Add support later on until we have more user friendly amount input modal.
|
|
// {
|
|
// name: 'amount',
|
|
// text: 'Amount',
|
|
// },
|
|
{
|
|
name: 'cleared',
|
|
text: t('Cleared'),
|
|
},
|
|
]}
|
|
/>
|
|
</Popover>
|
|
|
|
<Button
|
|
variant="bare"
|
|
ref={moreOptionsMenuTriggerRef}
|
|
aria-label={t('More options')}
|
|
onPress={() => {
|
|
setIsMoreOptionsMenuOpen(true);
|
|
}}
|
|
{...buttonProps}
|
|
>
|
|
<SvgDotsHorizontalTriple
|
|
width={16}
|
|
height={16}
|
|
style={{ color: 'currentColor' }}
|
|
/>
|
|
</Button>
|
|
|
|
<Popover
|
|
triggerRef={moreOptionsMenuTriggerRef}
|
|
isOpen={isMoreOptionsMenuOpen}
|
|
onOpenChange={() => setIsMoreOptionsMenuOpen(false)}
|
|
style={{ width: 200 }}
|
|
>
|
|
<Menu
|
|
getItemStyle={getMenuItemStyle}
|
|
style={{ backgroundColor: theme.floatingActionBarBackground }}
|
|
onMenuSelect={type => {
|
|
if (type === 'duplicate') {
|
|
onBatchDuplicate?.({
|
|
ids: selectedTransactionsArray,
|
|
onSuccess: ids => {
|
|
showUndoNotification({
|
|
message: t(
|
|
'Successfully duplicated {{count}} transactions.',
|
|
{ count: ids.length },
|
|
),
|
|
});
|
|
},
|
|
});
|
|
} else if (type === 'link-schedule') {
|
|
onBatchLinkSchedule?.({
|
|
ids: selectedTransactionsArray,
|
|
onSuccess: (ids, schedule) => {
|
|
// TODO: When schedule becomes available in mobile, update undo notification message
|
|
// with `messageActions` to open the schedule when the schedule name is clicked.
|
|
showUndoNotification({
|
|
message: t(
|
|
'Successfully linked {{count}} transactions to {{schedule}}.',
|
|
{ count: ids.length, schedule: schedule.name },
|
|
),
|
|
});
|
|
},
|
|
});
|
|
} else if (type === 'unlink-schedule') {
|
|
onBatchUnlinkSchedule?.({
|
|
ids: selectedTransactionsArray,
|
|
onSuccess: ids => {
|
|
showUndoNotification({
|
|
message: t(
|
|
'Successfully unlinked {{count}} transactions from their respective schedules.',
|
|
{ count: ids.length },
|
|
),
|
|
});
|
|
},
|
|
});
|
|
} else if (type === 'delete') {
|
|
onBatchDelete?.({
|
|
ids: selectedTransactionsArray,
|
|
onSuccess: ids => {
|
|
showUndoNotification({
|
|
type: 'warning',
|
|
message: t(
|
|
'Successfully deleted {{count}} transactions.',
|
|
{ count: ids.length },
|
|
),
|
|
});
|
|
},
|
|
});
|
|
} else if (type === 'transfer') {
|
|
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
|
|
showUndoNotification({
|
|
message: t(
|
|
'Successfully marked {{count}} transactions as transfer.',
|
|
{
|
|
count: ids.length,
|
|
},
|
|
),
|
|
}),
|
|
);
|
|
} else if (type === 'merge') {
|
|
onMerge?.(selectedTransactionsArray, () =>
|
|
showUndoNotification({
|
|
message: t('Successfully merged transactions'),
|
|
}),
|
|
);
|
|
}
|
|
setIsMoreOptionsMenuOpen(false);
|
|
}}
|
|
items={moreOptionsMenuItems}
|
|
/>
|
|
</Popover>
|
|
</View>
|
|
</View>
|
|
</FloatingActionBar>
|
|
);
|
|
}
|