Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow marking transactions as Transfers on mobile devices #4511

Merged
merged 16 commits into from
Mar 4, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 8 additions & 43 deletions packages/desktop-client/src/components/accounts/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import {
type PagedQuery,
} from 'loot-core/client/query-helpers';
import { type AppDispatch } from 'loot-core/client/store';
import { validForTransfer } from 'loot-core/client/transfer';
import { send, listen } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
import { type UndoState } from 'loot-core/server/undo';
Expand Down Expand Up @@ -308,6 +307,7 @@ type AccountInternalProps = {
hideFraction: boolean;
accountsSyncing: string[];
dispatch: AppDispatch;
onSetTransfer: ReturnType<typeof useTransactionBatchActions>['onSetTransfer'];
};
type AccountInternalState = {
search: string;
Expand Down Expand Up @@ -1350,49 +1350,11 @@ class AccountInternal extends PureComponent<
};

onSetTransfer = async (ids: string[]) => {
const onConfirmTransfer = async (ids: string[]) => {
this.setState({ workingHard: true });

const payees = this.props.payees;

const { data: transactions } = await runQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*'),
);
const [fromTrans, toTrans] = transactions;

if (transactions.length === 2 && validForTransfer(fromTrans, toTrans)) {
const fromPayee = payees.find(
p => p.transfer_acct === fromTrans.account,
);
const toPayee = payees.find(p => p.transfer_acct === toTrans.account);

const changes = {
updated: [
{
...fromTrans,
payee: toPayee?.id,
transfer_id: toTrans.id,
},
{
...toTrans,
payee: fromPayee?.id,
transfer_id: fromTrans.id,
},
],
};

await send('transactions-batch-update', changes);
}

await this.refetchTransactions();
};

await this.checkForReconciledTransactions(
this.setState({ workingHard: true });
await this.props.onSetTransfer(
ids,
'batchEditWithReconciled',
onConfirmTransfer,
this.props.payees,
this.refetchTransactions,
);
};

Expand Down Expand Up @@ -1937,6 +1899,7 @@ type AccountHackProps = Omit<
| 'onBatchLinkSchedule'
| 'onBatchUnlinkSchedule'
| 'onBatchDelete'
| 'onSetTransfer'
>;

function AccountHack(props: AccountHackProps) {
Expand All @@ -1948,6 +1911,7 @@ function AccountHack(props: AccountHackProps) {
onBatchLinkSchedule,
onBatchUnlinkSchedule,
onBatchDelete,
onSetTransfer,
} = useTransactionBatchActions();

return (
Expand All @@ -1959,6 +1923,7 @@ function AccountHack(props: AccountHackProps) {
onBatchLinkSchedule={onBatchLinkSchedule}
onBatchUnlinkSchedule={onBatchUnlinkSchedule}
onBatchDelete={onBatchDelete}
onSetTransfer={onSetTransfer}
{...props}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@
onSearch={onSearch}
onOpenTransaction={onOpenTransaction}
onRefresh={onRefresh}
account={account}

Check failure on line 350 in packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'AccountEntity | undefined' is not assignable to type 'AccountEntity'.
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
onLoadMore={loadMoreTransactions}
onOpenTransaction={onOpenTransaction}
onRefresh={undefined}
account={null}

Check failure on line 131 in packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx

View workflow job for this annotation

GitHub Actions / typecheck

Type 'null' is not assignable to type 'AccountEntity'.
/>
</SchedulesProvider>
</Page>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,22 @@
import { Trans, useTranslation } from 'react-i18next';

import { Button } from '@actual-app/components/button';
import { Menu, type MenuItemObject } from '@actual-app/components/menu';
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 { View } from '@actual-app/components/view';

import { setNotificationInset } from 'loot-core/client/actions';
import { validForTransfer } from 'loot-core/client/transfer';
import * as monthUtils from 'loot-core/shared/months';
import { isPreviewId } from 'loot-core/shared/transactions';
import { groupById, integerToCurrency } from 'loot-core/shared/util';
import { AccountEntity } from 'loot-core/types/models';

Check warning on line 28 in packages/desktop-client/src/components/mobile/transactions/TransactionList.tsx

View workflow job for this annotation

GitHub Actions / lint

All imports in the declaration are only used as types. Use `import type`
import { type TransactionEntity } from 'loot-core/types/models/transaction';

import { useAccounts } from '../../../hooks/useAccounts';
Expand Down Expand Up @@ -72,6 +78,7 @@
onOpenTransaction?: (transaction: TransactionEntity) => void;
isLoadingMore: boolean;
onLoadMore: () => void;
account: AccountEntity;
};

export function TransactionList({
Expand All @@ -80,6 +87,7 @@
onOpenTransaction,
isLoadingMore,
onLoadMore,
account,
}: TransactionListProps) {
const { t } = useTranslation();
const sections = useMemo(() => {
Expand Down Expand Up @@ -205,7 +213,10 @@
)}

{selectedTransactions.size > 0 && (
<SelectedTransactionsFloatingActionBar transactions={transactions} />
<SelectedTransactionsFloatingActionBar
transactions={transactions}
showMakeTransfer={!account}
/>
)}
</>
);
Expand All @@ -214,12 +225,15 @@
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);
Expand All @@ -228,6 +242,7 @@
<T extends string>(item: MenuItemObject<T>) => ({
...styles.mobileMenuItem,
color: theme.mobileHeaderText,
...(item.disabled === true && { color: theme.buttonBareDisabledText }),
...(item.name === 'delete' && { color: theme.errorTextMenu }),
}),
[],
Expand Down Expand Up @@ -268,6 +283,7 @@
onBatchDelete,
onBatchLinkSchedule,
onBatchUnlinkSchedule,
onSetTransfer,
} = useTransactionBatchActions();

const navigate = useNavigate();
Expand All @@ -288,6 +304,49 @@
};
}, [dispatch]);

const canBeTransfer = useMemo(() => {
// only two selected
if (selectedTransactionsArray.length !== 2) {
return false;
}
const fromTrans = transactions.find(
t => t.id === selectedTransactionsArray[0],
);
const toTrans = transactions.find(
t => t.id === selectedTransactionsArray[1],
);

// previously selected transactions aren't always present in current transaction list
if (!fromTrans || !toTrans) {
return false;
}

return validForTransfer(fromTrans, toTrans);
}, [selectedTransactionsArray, transactions]);

const moreOptionsMenuItems: MenuItem<string>[] = [
{
name: 'duplicate',
text: t('Duplicate'),
},
{
name: allTransactionsAreLinked ? 'unlink-schedule' : 'link-schedule',
text: t((allTransactionsAreLinked ? 'Unlink' : 'Link') + ' schedule'),
},
{
name: 'delete',
text: t('Delete'),
},
];

if (showMakeTransfer) {
moreOptionsMenuItems.splice(2, 0, {
name: 'transfer',
text: t('Make transfer'),
disabled: !canBeTransfer,
});
}

return (
<FloatingActionBar style={style}>
<View
Expand Down Expand Up @@ -334,7 +393,7 @@
<Button
variant="bare"
ref={editMenuTriggerRef}
aria-label="Edit fields"
aria-label={t('Edit fields')}
onPress={() => {
setIsEditMenuOpen(true);
}}
Expand All @@ -357,7 +416,7 @@
name,
ids: selectedTransactionsArray,
onSuccess: (ids, name, value, mode) => {
let displayValue = value;
let displayValue;
switch (name) {
case 'account':
displayValue =
Expand Down Expand Up @@ -417,19 +476,19 @@
// },
{
name: 'account',
text: 'Account',
text: t('Account'),
},
{
name: 'payee',
text: 'Payee',
text: t('Payee'),
},
{
name: 'notes',
text: 'Notes',
text: t('Notes'),
},
{
name: 'category',
text: 'Category',
text: t('Category'),
},
// Add support later on until we have more user friendly amount input modal.
// {
Expand All @@ -438,7 +497,7 @@
// },
{
name: 'cleared',
text: 'Cleared',
text: t('Cleared'),
},
]}
/>
Expand All @@ -447,7 +506,7 @@
<Button
variant="bare"
ref={moreOptionsMenuTriggerRef}
aria-label="More options"
aria-label={t('More options')}
onPress={() => {
setIsMoreOptionsMenuOpen(true);
}}
Expand Down Expand Up @@ -475,7 +534,10 @@
ids: selectedTransactionsArray,
onSuccess: ids => {
showUndoNotification({
message: `Successfully duplicated ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`,
message: t(
'Successfully duplicated {{count}} transactions.',
{ count: ids.length },
),
});
},
});
Expand All @@ -486,7 +548,10 @@
// 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: `Successfully linked ${ids.length} transaction${ids.length > 1 ? 's' : ''} to ${schedule.name}.`,
message: t(
'Successfully linked {{count}} transactions to {{schedule}}.',
{ count: ids.length, schedule: schedule.name },
),
});
},
});
Expand All @@ -495,7 +560,10 @@
ids: selectedTransactionsArray,
onSuccess: ids => {
showUndoNotification({
message: `Successfully unlinked ${ids.length} transaction${ids.length > 1 ? 's' : ''} from their respective schedules.`,
message: t(
'Successfully unlinked {{count}} transactions from their respective schedules.',
{ count: ids.length },
),
});
},
});
Expand All @@ -505,36 +573,28 @@
onSuccess: ids => {
showUndoNotification({
type: 'warning',
message: `Successfully deleted ${ids.length} transaction${ids.length > 1 ? 's' : ''}.`,
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,
},
),
}),
);
}
setIsMoreOptionsMenuOpen(false);
}}
items={[
{
name: 'duplicate',
text: 'Duplicate',
},
...(allTransactionsAreLinked
? [
{
name: 'unlink-schedule',
text: 'Unlink schedule',
},
]
: [
{
name: 'link-schedule',
text: 'Link schedule',
},
]),
{
name: 'delete',
text: 'Delete',
},
]}
items={moreOptionsMenuItems}
/>
</Popover>
</View>
Expand Down
Loading
Loading