Files
actual/packages/desktop-client/src/hooks/useTransactionBatchActions.ts
guiza b6f80c26e6 fix split transaction sort order when duplicating (#5911)
* Fixes #5715

* Release notes

* [autofix.ci] apply automated fixes

* Fix release notes PR number

* Shorten release notes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-22 01:32:00 +01:00

527 lines
15 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import {
deleteTransaction,
realizeTempTransactions,
ungroupTransaction,
ungroupTransactions,
updateTransaction,
} from 'loot-core/shared/transactions';
import { validForTransfer } from 'loot-core/shared/transfer';
import { applyChanges, type Diff } from 'loot-core/shared/util';
import {
type PayeeEntity,
type AccountEntity,
type ScheduleEntity,
type TransactionEntity,
} from 'loot-core/types/models';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch } from '@desktop-client/redux';
type BatchEditProps = {
name: keyof TransactionEntity;
ids: Array<TransactionEntity['id']>;
onSuccess?: (
ids: Array<TransactionEntity['id']>,
name: keyof TransactionEntity,
value: string | number | boolean | null,
mode: 'prepend' | 'append' | 'replace' | null | undefined,
) => void;
};
type BatchDuplicateProps = {
ids: Array<TransactionEntity['id']>;
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
};
type BatchDeleteProps = {
ids: Array<TransactionEntity['id']>;
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
};
type BatchLinkScheduleProps = {
ids: Array<TransactionEntity['id']>;
account?: AccountEntity | undefined;
onSuccess?: (
ids: Array<TransactionEntity['id']>,
schedule: ScheduleEntity,
) => void;
};
type BatchUnlinkScheduleProps = {
ids: Array<TransactionEntity['id']>;
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
};
export function useTransactionBatchActions() {
const dispatch = useDispatch();
const { t } = useTranslation();
const onBatchEdit = async ({ name, ids, onSuccess }: BatchEditProps) => {
const { data } = await aqlQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
const transactions = ungroupTransactions(data as TransactionEntity[]);
const onChange = async (
name: keyof TransactionEntity,
value: string | number | boolean | null,
mode?: 'prepend' | 'append' | 'replace' | null | undefined,
) => {
let transactionsToChange = transactions;
value = value === null ? '' : value;
const changes: Diff<TransactionEntity> = {
added: [],
deleted: [],
updated: [],
};
// Cleared is a special case right now
if (name === 'cleared') {
// Clear them if any are uncleared, otherwise unclear them
value = !!transactionsToChange.find(t => !t.cleared);
}
const idSet = new Set(ids);
transactionsToChange.forEach(trans => {
if (name === 'cleared' && trans.reconciled) {
// Skip transactions that are reconciled. Don't want to set them as
// uncleared.
return;
}
if (!idSet.has(trans.id)) {
// Skip transactions which aren't actually selected, since the query
// above also retrieves the siblings & parent of any selected splits.
return;
}
let valueToSet = value;
if (name === 'notes') {
if (mode === 'prepend') {
valueToSet =
trans.notes === null ? value : `${value}${trans.notes}`;
} else if (mode === 'append') {
valueToSet =
trans.notes === null ? value : `${trans.notes}${value}`;
} else if (mode === 'replace') {
valueToSet = value;
}
}
const transaction = {
...trans,
[name]: valueToSet,
};
if (name === 'account' && trans.account !== value) {
transaction.reconciled = false;
}
const { diff } = updateTransaction(transactionsToChange, transaction);
// TODO: We need to keep an updated list of transactions so
// the logic in `updateTransaction`, particularly about
// updating split transactions, works. This isn't ideal and we
// should figure something else out
transactionsToChange = applyChanges<TransactionEntity>(
diff,
transactionsToChange,
);
changes.deleted = changes.deleted
? changes.deleted.concat(diff.deleted)
: diff.deleted;
changes.updated = changes.updated
? changes.updated.concat(diff.updated)
: diff.updated;
changes.added = changes.added
? changes.added.concat(diff.added)
: diff.added;
});
await send('transactions-batch-update', changes);
onSuccess?.(ids, name, value, mode);
};
const pushPayeeAutocompleteModal = () => {
dispatch(
pushModal({
modal: {
name: 'payee-autocomplete',
options: {
onSelect: payeeId => onChange(name, payeeId),
},
},
}),
);
};
const pushAccountAutocompleteModal = () => {
dispatch(
pushModal({
modal: {
name: 'account-autocomplete',
options: {
onSelect: accountId => onChange(name, accountId),
},
},
}),
);
};
const pushEditField = () => {
if (name !== 'date' && name !== 'amount' && name !== 'notes') {
return;
}
dispatch(
pushModal({
modal: {
name: 'edit-field',
options: {
name,
onSubmit: (name, value, mode) => onChange(name, value, mode),
},
},
}),
);
};
const pushCategoryAutocompleteModal = () => {
// Only show balances when all selected transaction are in the same month.
const transactionMonth = transactions[0]?.date
? monthUtils.monthFromDate(transactions[0]?.date)
: null;
const transactionsHaveSameMonth =
transactionMonth &&
transactions.every(
t => monthUtils.monthFromDate(t.date) === transactionMonth,
);
dispatch(
pushModal({
modal: {
name: 'category-autocomplete',
options: {
month: transactionsHaveSameMonth ? transactionMonth : undefined,
onSelect: categoryId => onChange(name, categoryId),
},
},
}),
);
};
if (
name === 'amount' ||
name === 'payee' ||
name === 'account' ||
name === 'date'
) {
const reconciledTransactions = transactions.filter(t => t.reconciled);
if (reconciledTransactions.length > 0) {
dispatch(
pushModal({
modal: {
name: 'confirm-transaction-edit',
options: {
onConfirm: () => {
if (name === 'payee') {
pushPayeeAutocompleteModal();
} else if (name === 'account') {
pushAccountAutocompleteModal();
} else {
pushEditField();
}
},
confirmReason: 'batchEditWithReconciled',
},
},
}),
);
return;
}
}
if (name === 'cleared') {
// Cleared just toggles it on/off and it depends on the data
// loaded. Need to clean this up in the future.
onChange('cleared', null);
} else if (name === 'category') {
pushCategoryAutocompleteModal();
} else if (name === 'payee') {
pushPayeeAutocompleteModal();
} else if (name === 'account') {
pushAccountAutocompleteModal();
} else {
pushEditField();
}
};
const onBatchDuplicate = async ({ ids, onSuccess }: BatchDuplicateProps) => {
const onConfirmDuplicate = async (ids: Array<TransactionEntity['id']>) => {
const { data } = await aqlQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
const transactions = data as TransactionEntity[];
const changes = {
added: transactions.reduce(
(newTransactions: TransactionEntity[], trans: TransactionEntity) => {
return newTransactions.concat(
realizeTempTransactions(ungroupTransaction(trans)),
);
},
[],
),
};
await send('transactions-batch-update', changes);
onSuccess?.(ids);
};
await checkForReconciledTransactions(
ids,
'batchDuplicateWithReconciled',
onConfirmDuplicate,
);
};
const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => {
const onConfirmDelete = (ids: Array<TransactionEntity['id']>) => {
dispatch(
pushModal({
modal: {
name: 'confirm-delete',
options: {
message:
ids.length > 1
? t(
'Are you sure you want to delete these {{count}} transactions?',
{ count: ids.length },
)
: t('Are you sure you want to delete the transaction?'),
onConfirm: async () => {
const { data } = await aqlQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
let transactions = ungroupTransactions(
data as TransactionEntity[],
);
const idSet = new Set(ids);
const changes: Diff<TransactionEntity> = {
added: [],
deleted: [],
updated: [],
};
transactions.forEach(trans => {
const parentId = trans.parent_id;
// First, check if we're actually deleting this transaction by
// checking `idSet`. Then, we don't need to do anything if it's
// a child transaction and the parent is already being deleted
if (
!idSet.has(trans.id) ||
(parentId && idSet.has(parentId))
) {
return;
}
const { diff } = deleteTransaction(transactions, trans.id);
// TODO: We need to keep an updated list of transactions so
// the logic in `updateTransaction`, particularly about
// updating split transactions, works. This isn't ideal and we
// should figure something else out
transactions = applyChanges<TransactionEntity>(
diff,
transactions,
);
changes.deleted = diff.deleted
? changes.deleted.concat(diff.deleted)
: diff.deleted;
changes.updated = diff.updated
? changes.updated.concat(diff.updated)
: diff.updated;
});
await send('transactions-batch-update', changes);
onSuccess?.(ids);
},
},
},
}),
);
};
await checkForReconciledTransactions(
ids,
'batchDeleteWithReconciled',
onConfirmDelete,
);
};
const onBatchLinkSchedule = async ({
ids,
account,
onSuccess,
}: BatchLinkScheduleProps) => {
const { data: transactions } = await aqlQuery(
q('transactions')
.filter({ id: { $oneof: ids } })
.select('*')
.options({ splits: 'grouped' }),
);
dispatch(
pushModal({
modal: {
name: 'schedule-link',
options: {
transactionIds: ids,
getTransaction: (id: TransactionEntity['id']) =>
transactions.find((t: TransactionEntity) => t.id === id),
accountName: account?.name ?? '',
onScheduleLinked: schedule => {
onSuccess?.(ids, schedule);
},
},
},
}),
);
};
const onBatchUnlinkSchedule = async ({
ids,
onSuccess,
}: BatchUnlinkScheduleProps) => {
const changes = {
updated: ids.map(
id => ({ id, schedule: null }) as unknown as Partial<TransactionEntity>,
),
};
await send('transactions-batch-update', changes);
onSuccess?.(ids);
};
const checkForReconciledTransactions = async (
ids: Array<TransactionEntity['id']>,
confirmReason: string,
onConfirm: (ids: Array<TransactionEntity['id']>) => void,
) => {
const { data } = await aqlQuery(
q('transactions')
.filter({ id: { $oneof: ids }, reconciled: true })
.select('*')
.options({ splits: 'grouped' }),
);
const transactions = ungroupTransactions(data as TransactionEntity[]);
if (transactions.length > 0) {
dispatch(
pushModal({
modal: {
name: 'confirm-transaction-edit',
options: {
onConfirm: () => {
onConfirm(ids);
},
confirmReason,
},
},
}),
);
} else {
onConfirm(ids);
}
};
const onSetTransfer = async (
ids: string[],
payees: PayeeEntity[],
onSuccess: (ids: string[]) => void,
) => {
const onConfirmTransfer = async (ids: string[]) => {
const { data: transactions } = await aqlQuery(
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,
category: null,
payee: toPayee?.id,
transfer_id: toTrans.id,
},
{
...toTrans,
category: null,
payee: fromPayee?.id,
transfer_id: fromTrans.id,
},
],
runTransfers: false,
};
await send('transactions-batch-update', changes);
}
onSuccess?.(ids);
};
await checkForReconciledTransactions(
ids,
'batchEditWithReconciled',
onConfirmTransfer,
);
};
const onMerge = async (ids: string[], onSuccess: () => void) => {
await send(
'transactions-merge',
ids.map(id => ({ id })),
);
onSuccess();
};
return {
onBatchEdit,
onBatchDuplicate,
onBatchDelete,
onBatchLinkSchedule,
onBatchUnlinkSchedule,
onSetTransfer,
onMerge,
};
}