diff --git a/packages/desktop-client/e2e/rules.test.js b/packages/desktop-client/e2e/rules.test.js index 20cc4f7d172b5bee98dc10b0050d30cc56f2728c..71c16d0cde1078d7626b4ce0cb30f0600028876a 100644 --- a/packages/desktop-client/e2e/rules.test.js +++ b/packages/desktop-client/e2e/rules.test.js @@ -120,7 +120,7 @@ test.describe('Rules', () => { }); const transaction = accountPage.getNthTransaction(0); - await expect(transaction.payee).toHaveText('Ikea'); + await expect(transaction.payee).toHaveText('Split'); await expect(transaction.notes).toHaveText('food / entertainment'); await expect(transaction.category).toHaveText('Split'); await expect(transaction.debit).toHaveText('100.00'); diff --git a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-1-chromium-linux.png b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-1-chromium-linux.png index 8ae36b41d02ca7d915f036280bd1a468ced670a1..f677aafa811024cf31eb71f45e8383dbe02e11ae 100644 Binary files a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-1-chromium-linux.png and b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-2-chromium-linux.png b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-2-chromium-linux.png index ff186283451bd3bc6f691373403e942f48acccb3..c3620db1d281278e0f1b3c1b7e5399addf89b30d 100644 Binary files a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-2-chromium-linux.png and b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-3-chromium-linux.png b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-3-chromium-linux.png index ea44917d0d870e3d5532daf34847ce2d5f27c951..7490e7d3318415efa6680634f50a629830804b4c 100644 Binary files a/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-3-chromium-linux.png and b/packages/desktop-client/e2e/rules.test.js-snapshots/Rules-creates-a-split-transaction-rule-and-makes-sure-it-is-applied-when-creating-a-transaction-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js b/packages/desktop-client/e2e/transactions.test.js index f0ea08d796feba991c5dad6b0298cbbae59efc1d..91bdd02c485d73a7f866adc05f99a3cbb5961c47 100644 --- a/packages/desktop-client/e2e/transactions.test.js +++ b/packages/desktop-client/e2e/transactions.test.js @@ -120,7 +120,7 @@ test.describe('Transactions', () => { ]); const firstTransaction = accountPage.getNthTransaction(0); - await expect(firstTransaction.payee).toHaveText('Krogger'); + await expect(firstTransaction.payee).toHaveText('Split'); await expect(firstTransaction.notes).toHaveText('Notes'); await expect(firstTransaction.category).toHaveText('Split'); await expect(firstTransaction.debit).toHaveText('333.33'); diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-1-chromium-linux.png index 9e5f39e6e79a39bedd9f2f0cce58b2f46b2ca033..987db8a0c82dcbca515b9fd419fb4acb1527efc4 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-2-chromium-linux.png index 5cb575b8e0daafd6e516ab278da16ca7cc88b0d0..123571a606f7d16b1a53e93199d62e5daefd168a 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-3-chromium-linux.png index 44649349d2e4721da7df30f89f95571a7f30491d..ae65b4cb2386051c967c5bc50c4d4892200f7d94 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-creates-a-split-test-transaction-3-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index cb814ec23d2f44f4ad7ab23bd00a5012811d6cea..81f48f391e5d39c72f4f737824ad9cc69d9fc7ad 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -401,7 +401,6 @@ export function Modals() { actions={actions} transactionIds={options?.transactionIds} getTransaction={options?.getTransaction} - pushModal={options?.pushModal} /> ); diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index beac95b915d34f9b0d833841833580de23bbd7d4..5ae43fe4270f8e0a7d3feb3f4470877dae399f0b 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -1,8 +1,9 @@ import React, { PureComponent, createRef, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom'; +import { Navigate, useParams, useLocation } from 'react-router-dom'; import { debounce } from 'debounce'; +import { v4 as uuidv4 } from 'uuid'; import { validForTransfer } from 'loot-core/client/transfer'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; @@ -20,6 +21,8 @@ import { realizeTempTransactions, ungroupTransaction, ungroupTransactions, + makeChild, + makeAsNonChildTransactions, } from 'loot-core/src/shared/transactions'; import { applyChanges, groupById } from 'loot-core/src/shared/util'; @@ -1049,6 +1052,114 @@ class AccountInternal extends PureComponent { ); }; + onMakeAsSplitTransaction = async ids => { + this.setState({ workingHard: true }); + + const { data: transactions } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'none' }), + ); + + if (!transactions || transactions.length === 0) { + return; + } + + const [firstTransaction] = transactions; + const parentTransaction = { + id: uuidv4(), + is_parent: true, + cleared: transactions.every(t => !!t.cleared), + date: firstTransaction.date, + account: firstTransaction.account, + amount: transactions + .map(t => t.amount) + .reduce((total, amount) => total + amount, 0), + }; + const childTransactions = transactions.map(t => + makeChild(parentTransaction, t), + ); + + await send('transactions-batch-update', { + added: [parentTransaction], + updated: childTransactions, + }); + + this.refetchTransactions(); + }; + + onMakeAsNonSplitTransactions = async ids => { + this.setState({ workingHard: true }); + + const { data: groupedTransactions } = await runQuery( + q('transactions') + .filter({ id: { $oneof: ids } }) + .select('*') + .options({ splits: 'grouped' }), + ); + + let changes = { + updated: [], + deleted: [], + }; + + const groupedTransactionsToUpdate = groupedTransactions.filter( + t => t.is_parent, + ); + + for (const groupedTransaction of groupedTransactionsToUpdate) { + const transactions = ungroupTransaction(groupedTransaction); + const [parentTransaction, ...childTransactions] = transactions; + + if (ids.includes(parentTransaction.id)) { + // Unsplit all child transactions. + const diff = makeAsNonChildTransactions( + childTransactions, + transactions, + ); + + changes = { + updated: [...changes.updated, ...diff.updated], + deleted: [...changes.deleted, ...diff.deleted], + }; + + // Already processed the child transactions above, no need to process them below. + continue; + } + + // Unsplit selected child transactions. + + const selectedChildTransactions = childTransactions.filter(t => + ids.includes(t.id), + ); + + if (selectedChildTransactions.length === 0) { + continue; + } + + const diff = makeAsNonChildTransactions( + selectedChildTransactions, + transactions, + ); + + changes = { + updated: [...changes.updated, ...diff.updated], + deleted: [...changes.deleted, ...diff.deleted], + }; + } + + await send('transactions-batch-update', changes); + + this.refetchTransactions(); + + const transactionsToSelect = changes.updated.map(t => t.id); + this.dispatchSelected({ + type: 'select-all', + ids: transactionsToSelect, + }); + }; + checkForReconciledTransactions = async (ids, confirmReason, onConfirm) => { const { data } = await runQuery( q('transactions') @@ -1610,6 +1721,8 @@ class AccountInternal extends PureComponent { onApplyFilter={this.onApplyFilter} onScheduleAction={this.onScheduleAction} onSetTransfer={this.onSetTransfer} + onMakeAsSplitTransaction={this.onMakeAsSplitTransaction} + onMakeAsNonSplitTransactions={this.onMakeAsNonSplitTransactions} /> <View style={{ flex: 1 }}> @@ -1686,13 +1799,11 @@ class AccountInternal extends PureComponent { function AccountHack(props) { const { dispatch: splitsExpandedDispatch } = useSplitsExpanded(); - const match = useMatch(props.location.pathname); return ( <AccountInternal - {...props} - match={match} splitsExpandedDispatch={splitsExpandedDispatch} + {...props} /> ); } diff --git a/packages/desktop-client/src/components/accounts/Header.jsx b/packages/desktop-client/src/components/accounts/Header.jsx index fd29b4cc42781c7391e34d7d4e40a2156f039d48..4506034fcd4904bb3980cea21d7eed771302f5e1 100644 --- a/packages/desktop-client/src/components/accounts/Header.jsx +++ b/packages/desktop-client/src/components/accounts/Header.jsx @@ -26,7 +26,7 @@ import { View } from '../common/View'; import { FilterButton } from '../filters/FiltersMenu'; import { FiltersStack } from '../filters/FiltersStack'; import { NotesButton } from '../NotesButton'; -import { SelectedTransactionsButton } from '../transactions/SelectedTransactions'; +import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton'; import { Balances } from './Balance'; import { ReconcilingMessage, ReconcileMenu } from './Reconcile'; @@ -84,6 +84,8 @@ export function AccountHeader({ onDeleteFilter, onScheduleAction, onSetTransfer, + onMakeAsSplitTransaction, + onMakeAsNonSplitTransactions, }) { const [menuOpen, setMenuOpen] = useState(false); const searchInput = useRef(null); @@ -319,6 +321,8 @@ export function AccountHeader({ onScheduleAction={onScheduleAction} pushModal={pushModal} showMakeTransfer={showMakeTransfer} + onMakeAsSplitTransaction={onMakeAsSplitTransaction} + onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions} /> )} <Button diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index 62593477e15141ac8874fb9e0b26b1d7ec39cbf4..8018f1ef7bd9124dc5cf8dd8e867ef1cef5ba90a 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -362,7 +362,7 @@ const ChildTransactionEdit = forwardRef( <View> <FieldLabel title="Category" /> <TapField - style={{ + textStyle={{ ...((isOffBudget || isBudgetTransfer(transaction)) && { fontStyle: 'italic', color: theme.pageTextSubdued, @@ -490,6 +490,9 @@ const TransactionEditInner = memo(function TransactionEditInner({ }; const getPrettyPayee = trans => { + if (trans && trans.is_parent) { + return 'Split'; + } const transPayee = trans && getPayee(trans); const transTransferAcct = trans && getTransferAcct(trans); return getDescriptionPretty(trans, transPayee, transTransferAcct); @@ -763,11 +766,17 @@ const TransactionEditInner = memo(function TransactionEditInner({ <View> <FieldLabel title="Payee" /> <TapField + textStyle={{ + ...(transaction.is_parent && { + fontStyle: 'italic', + fontWeight: 300, + }), + }} + value={getPrettyPayee(transaction)} disabled={ editingField && editingField !== getFieldName(transaction.id, 'payee') } - value={getPrettyPayee(transaction)} onClick={() => onEditField(transaction.id, 'payee')} data-testid="payee-field" /> diff --git a/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx b/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx index 5c860cacc38215afb0114c4b4e017c3daf0b19cf..952361cacc735f1186bde7537e817853d1f6b3c3 100644 --- a/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx +++ b/packages/desktop-client/src/components/modals/PayeeAutocompleteModal.tsx @@ -1,6 +1,7 @@ import React, { type ComponentPropsWithoutRef } from 'react'; import { useAccounts } from '../../hooks/useAccounts'; +import { useNavigate } from '../../hooks/useNavigate'; import { usePayees } from '../../hooks/usePayees'; import { useResponsive } from '../../ResponsiveProvider'; import { theme } from '../../style'; @@ -21,6 +22,7 @@ export function PayeeAutocompleteModal({ }: PayeeAutocompleteModalProps) { const payees = usePayees() || []; const accounts = useAccounts() || []; + const navigate = useNavigate(); const _onClose = () => { modalProps.onClose(); @@ -32,6 +34,8 @@ export function PayeeAutocompleteModal({ containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } }, }; + const onManagePayees = () => navigate('/payees'); + return ( <Modal title={ @@ -56,20 +60,19 @@ export function PayeeAutocompleteModal({ /> )} > - {() => ( - <PayeeAutocomplete - payees={payees} - accounts={accounts} - focused={true} - embedded={true} - closeOnBlur={false} - onClose={_onClose} - showManagePayees={false} - showMakeTransfer={!isNarrowWidth} - {...defaultAutocompleteProps} - {...autocompleteProps} - /> - )} + <PayeeAutocomplete + payees={payees} + accounts={accounts} + focused={true} + embedded={true} + closeOnBlur={false} + onClose={_onClose} + onManagePayees={onManagePayees} + showManagePayees={!isNarrowWidth} + showMakeTransfer={!isNarrowWidth} + {...defaultAutocompleteProps} + {...autocompleteProps} + /> </Modal> ); } diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx index e35d0791e41e9ec1b30e599bc8a2d235466ede0e..5743087d3fd7409b627eee4f2cf0c4602b577fb2 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx +++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx @@ -1,6 +1,8 @@ // @ts-strict-ignore import React, { useCallback, useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { pushModal } from 'loot-core/client/actions'; import { useSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { send } from 'loot-core/src/platform/client/fetch'; import { type Query } from 'loot-core/src/shared/query'; @@ -17,24 +19,18 @@ import { type CommonModalProps } from '../Modals'; import { ROW_HEIGHT, SchedulesTable } from './SchedulesTable'; -type ModalParams = { - id: string; - transaction: TransactionEntity; -}; - export function ScheduleLink({ modalProps, actions, transactionIds: ids, getTransaction, - pushModal, }: { actions: BoundActions; modalProps?: CommonModalProps; transactionIds: string[]; getTransaction: (transactionId: string) => TransactionEntity; - pushModal: (name: string, params: ModalParams) => void; }) { + const dispatch = useDispatch(); const [filter, setFilter] = useState(''); const scheduleData = useSchedules({ @@ -59,10 +55,12 @@ export function ScheduleLink({ async function onCreate() { actions.popModal(); - pushModal('schedule-edit', { - id: null, - transaction: getTransaction(ids[0]), - }); + dispatch( + pushModal('schedule-edit', { + id: null, + transaction: getTransaction(ids[0]), + }), + ); } return ( diff --git a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx similarity index 51% rename from packages/desktop-client/src/components/transactions/SelectedTransactions.jsx rename to packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx index d08e73c706c517084fd3a7a32b5795d4844e6804..5d982ebbe9e8f6aa5825c2e1e00a469510dac2d9 100644 --- a/packages/desktop-client/src/components/transactions/SelectedTransactions.jsx +++ b/packages/desktop-client/src/components/transactions/SelectedTransactionsButton.jsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useDispatch } from 'react-redux'; +import { pushModal } from 'loot-core/client/actions'; import { isPreviewId } from 'loot-core/shared/transactions'; import { validForTransfer } from 'loot-core/src/client/transfer'; @@ -18,43 +20,45 @@ export function SelectedTransactionsButton({ onCreateRule, onSetTransfer, onScheduleAction, - pushModal, showMakeTransfer, + onMakeAsSplitTransaction, + onMakeAsNonSplitTransactions, }) { + const dispatch = useDispatch(); const selectedItems = useSelectedItems(); + const selectedIds = useMemo(() => [...selectedItems], [selectedItems]); const types = useMemo(() => { - const items = [...selectedItems]; + const items = selectedIds; return { preview: !!items.find(id => isPreviewId(id)), trans: !!items.find(id => !isPreviewId(id)), }; - }, [selectedItems]); + }, [selectedIds]); const ambiguousDuplication = useMemo(() => { - const transactions = [...selectedItems].map(id => getTransaction(id)); + const transactions = selectedIds.map(id => getTransaction(id)); return transactions.some(t => t && t.is_child); - }, [selectedItems]); + }, [selectedIds, getTransaction]); const linked = useMemo(() => { return ( !types.preview && - [...selectedItems].every(id => { + selectedIds.every(id => { const t = getTransaction(id); return t && t.schedule; }) ); - }, [types.preview, selectedItems, getTransaction]); + }, [types.preview, selectedIds, getTransaction]); const canBeTransfer = useMemo(() => { // only two selected - if (selectedItems.size !== 2) { + if (selectedIds.length !== 2) { return false; } - const transactions = [...selectedItems]; - const fromTrans = getTransaction(transactions[0]); - const toTrans = getTransaction(transactions[1]); + const fromTrans = getTransaction(selectedIds[0]); + const toTrans = getTransaction(selectedIds[1]); // previously selected transactions aren't always present in current transaction list if (!fromTrans || !toTrans) { @@ -62,39 +66,83 @@ export function SelectedTransactionsButton({ } return validForTransfer(fromTrans, toTrans); - }, [selectedItems, getTransaction]); + }, [selectedIds, getTransaction]); + + const canMakeAsSplitTransaction = useMemo(() => { + if (selectedIds.length <= 1 || types.preview) { + return false; + } + + const transactions = selectedIds.map(id => getTransaction(id)); + const [firstTransaction] = transactions; + + const areAllSameDateAndAccount = transactions.every( + t => + t && + t.date === firstTransaction.date && + t.account === firstTransaction.account, + ); + const areNoSplitTransactions = transactions.every( + t => t && !t.is_parent && !t.is_child, + ); + const areNoReconciledTransactions = transactions.every( + t => t && !t.reconciled, + ); + + return ( + areAllSameDateAndAccount && + areNoSplitTransactions && + areNoReconciledTransactions + ); + }, [selectedIds, types, getTransaction]); + + const canUnsplitTransactions = useMemo(() => { + if (selectedIds.length === 0 || types.preview) { + return false; + } + + const transactions = selectedIds.map(id => getTransaction(id)); + + const areNoReconciledTransactions = transactions.every( + t => t && !t.reconciled, + ); + const areAllSplitTransactions = transactions.every( + t => t && (t.is_parent || t.is_child), + ); + return areNoReconciledTransactions && areAllSplitTransactions; + }, [selectedIds, types, getTransaction]); const hotKeyOptions = { enabled: types.trans, scopes: ['app'], }; - useHotkeys('f', () => onShow([...selectedItems]), hotKeyOptions, [ + useHotkeys('f', () => onShow(selectedIds), hotKeyOptions, [ onShow, - selectedItems, + selectedIds, ]); - useHotkeys('d', () => onDelete([...selectedItems]), hotKeyOptions, [ + useHotkeys('d', () => onDelete(selectedIds), hotKeyOptions, [ onDelete, - selectedItems, + selectedIds, ]); - useHotkeys('a', () => onEdit('account', [...selectedItems]), hotKeyOptions, [ + useHotkeys('a', () => onEdit('account', selectedIds), hotKeyOptions, [ onEdit, - selectedItems, + selectedIds, ]); - useHotkeys('p', () => onEdit('payee', [...selectedItems]), hotKeyOptions, [ + useHotkeys('p', () => onEdit('payee', selectedIds), hotKeyOptions, [ onEdit, - selectedItems, + selectedIds, ]); - useHotkeys('n', () => onEdit('notes', [...selectedItems]), hotKeyOptions, [ + useHotkeys('n', () => onEdit('notes', selectedIds), hotKeyOptions, [ onEdit, - selectedItems, + selectedIds, ]); - useHotkeys('c', () => onEdit('category', [...selectedItems]), hotKeyOptions, [ + useHotkeys('c', () => onEdit('category', selectedIds), hotKeyOptions, [ onEdit, - selectedItems, + selectedIds, ]); - useHotkeys('l', () => onEdit('cleared', [...selectedItems]), hotKeyOptions, [ + useHotkeys('l', () => onEdit('cleared', selectedIds), hotKeyOptions, [ onEdit, - selectedItems, + selectedIds, ]); return ( @@ -120,7 +168,7 @@ export function SelectedTransactionsButton({ { name: 'view-schedule', text: 'View schedule', - disabled: selectedItems.size > 1, + disabled: selectedIds.length > 1, }, { name: 'unlink-schedule', text: 'Unlink schedule' }, ] @@ -143,6 +191,24 @@ export function SelectedTransactionsButton({ }, ] : []), + ...(canMakeAsSplitTransaction + ? [ + { + name: 'make-as-split-transaction', + text: 'Make as split transaction', + }, + ] + : []), + ...(canUnsplitTransactions + ? [ + { + name: 'unsplit-transactions', + text: + 'Unsplit transaction' + + (selectedIds.length > 1 ? 's' : ''), + }, + ] + : []), Menu.line, { type: Menu.label, name: 'Edit field' }, { name: 'date', text: 'Date' }, @@ -157,20 +223,26 @@ export function SelectedTransactionsButton({ onSelect={name => { switch (name) { case 'show': - onShow([...selectedItems]); + onShow(selectedIds); break; case 'duplicate': - onDuplicate([...selectedItems]); + onDuplicate(selectedIds); break; case 'delete': - onDelete([...selectedItems]); + onDelete(selectedIds); + break; + case 'make-as-split-transaction': + onMakeAsSplitTransaction(selectedIds); + break; + case 'unsplit-transactions': + onMakeAsNonSplitTransactions(selectedIds); break; case 'post-transaction': case 'skip': - onScheduleAction(name, selectedItems); + onScheduleAction(name, selectedIds); break; case 'view-schedule': - const firstId = [...selectedItems][0]; + const firstId = selectedIds[0]; let scheduleId; if (isPreviewId(firstId)) { const parts = firstId.split('/'); @@ -181,27 +253,28 @@ export function SelectedTransactionsButton({ } if (scheduleId) { - pushModal('schedule-edit', { id: scheduleId }); + dispatch(pushModal('schedule-edit', { id: scheduleId })); } break; case 'link-schedule': - pushModal('schedule-link', { - transactionIds: [...selectedItems], - getTransaction, - pushModal, - }); + dispatch( + pushModal('schedule-link', { + transactionIds: selectedIds, + getTransaction, + }), + ); break; case 'unlink-schedule': - onUnlink([...selectedItems]); + onUnlink(selectedIds); break; case 'create-rule': - onCreateRule([...selectedItems]); + onCreateRule(selectedIds); break; case 'set-transfer': - onSetTransfer([...selectedItems]); + onSetTransfer(selectedIds); break; default: - onEdit(name, [...selectedItems]); + onEdit(name, selectedIds); } }} /> diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 20cf3a97717421818cfb40eea3c4d2621cb7814f..95c6e66fb5f262cb42b3c391ab24f6be6c47a189 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -90,9 +90,9 @@ export function TransactionList({ onCreatePayee, onApplyFilter, }) { + const dispatch = useDispatch(); const transactionsLatest = useRef(); const navigate = useNavigate(); - const dispatch = useDispatch(); useLayoutEffect(() => { transactionsLatest.current = transactions; @@ -158,7 +158,7 @@ export function TransactionList({ }, []); const onManagePayees = useCallback(id => { - navigate('/payees', { selectedPayee: id }); + navigate('/payees', { state: { selectedPayee: id } }); }); const onNavigateToTransferAccount = useCallback(accountId => { diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index adcf236f115ca55bf36eec871b6acb83c51a42c3..3633f8fa9b0e18ecb1da942232a7c3877859ef01 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -49,7 +49,7 @@ import { useMergedRefs } from '../../hooks/useMergedRefs'; import { usePrevious } from '../../hooks/usePrevious'; import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected'; import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; -import { SvgLeftArrow2, SvgRightArrow2 } from '../../icons/v0'; +import { SvgLeftArrow2, SvgRightArrow2, SvgSplit } from '../../icons/v0'; import { SvgArrowDown, SvgArrowUp, SvgCheveronDown } from '../../icons/v1'; import { SvgArrowsSynchronize, @@ -457,7 +457,6 @@ function PayeeCell({ id, payee, focused, - inherited, payees, accounts, valueStyle, @@ -473,16 +472,75 @@ function PayeeCell({ }) { const isCreatingPayee = useRef(false); - return ( + const dispatch = useDispatch(); + + return transaction.is_parent ? ( + <Cell + name="payee" + width="flex" + focused={focused} + style={{ padding: 0 }} + plain + > + <CellButton + bare + style={{ + alignSelf: 'flex-start', + borderRadius: 4, + border: '1px solid transparent', // so it doesn't shift on hover + ':hover': { + border: '1px solid ' + theme.buttonNormalBorder, + }, + }} + disabled={isPreview} + onSelect={() => + dispatch( + pushModal('payee-autocomplete', { + onSelect: payeeId => { + onUpdate('payee', payeeId); + }, + }), + ) + } + > + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'stretch', + borderRadius: 4, + flex: 1, + padding: 4, + color: theme.pageTextSubdued, + }} + > + <SvgSplit + style={{ + color: 'inherit', + width: 14, + height: 14, + marginRight: 2, + }} + /> + <Text + style={{ + fontStyle: 'italic', + fontWeight: 300, + userSelect: 'none', + }} + > + Split + </Text> + </View> + </CellButton> + </Cell> + ) : ( <CustomCell width="flex" name="payee" textAlign="flex" value={payee?.id} - valueStyle={{ - ...valueStyle, - ...(inherited && { color: theme.tableTextInactive }), - }} + valueStyle={valueStyle} exposed={focused} onExpose={name => !isPreview && onEdit(id, name)} onUpdate={async value => { @@ -614,41 +672,38 @@ function PayeeIcons({ ); } -const Transaction = memo(function Transaction(props) { - const { - transaction: originalTransaction, - subtransactions, - editing, - showAccount, - showBalance, - showCleared, - showZeroInDeposit, - style, - selected, - highlighted, - added, - matched, - expanded, - inheritedFields, - focusedField, - categoryGroups, - payees, - accounts, - balance, - dateFormat = 'MM/dd/yyyy', - hideFraction, - onSave, - onEdit, - onDelete, - onSplit, - onManagePayees, - onCreatePayee, - onToggleSplit, - onNavigateToTransferAccount, - onNavigateToSchedule, - onNotesTagClick, - } = props; - +const Transaction = memo(function Transaction({ + transaction: originalTransaction, + subtransactions, + editing, + showAccount, + showBalance, + showCleared, + showZeroInDeposit, + style, + selected, + highlighted, + added, + matched, + expanded, + focusedField, + categoryGroups, + payees, + accounts, + balance, + dateFormat = 'MM/dd/yyyy', + hideFraction, + onSave, + onEdit, + onDelete, + onSplit, + onManagePayees, + onCreatePayee, + onToggleSplit, + onNavigateToTransferAccount, + onNavigateToSchedule, + onNotesTagClick, +}) { const dispatch = useDispatch(); const dispatchSelected = useSelectedDispatch(); @@ -989,7 +1044,6 @@ const Transaction = memo(function Transaction(props) { id={id} payee={payee} focused={focusedField === 'payee'} - inherited={inheritedFields && inheritedFields.has('payee')} /* Filter out the account we're currently in as it is not a valid transfer */ accounts={accounts.filter(account => account.id !== accountId)} payees={payees.filter(payee => payee.transfer_acct !== accountId)} @@ -1090,6 +1144,7 @@ const Transaction = memo(function Transaction(props) { borderRadius: 4, flex: 1, padding: 4, + color: theme.pageTextSubdued, }} > {isParent && ( @@ -1103,7 +1158,13 @@ const Transaction = memo(function Transaction(props) { }} /> )} - <Text style={{ fontStyle: 'italic', userSelect: 'none' }}> + <Text + style={{ + fontStyle: 'italic', + fontWeight: 300, + userSelect: 'none', + }} + > Split </Text> </View> @@ -1632,9 +1693,6 @@ function TransactionTableInner({ accounts={accounts} categoryGroups={categoryGroups} payees={payees} - inheritedFields={ - parent?.payee === trans.payee ? new Set(['payee']) : new Set() - } dateFormat={dateFormat} hideFraction={hideFraction} onEdit={tableNavigator.onEdit} diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 941671c3266a8f317d1062669c904603b1c7906f..f45233af13203199e0c3686de7d4ef31d335a4a7 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -4,6 +4,7 @@ import type { CategoryEntity, CategoryGroupEntity, GoCardlessToken, + TransactionEntity, } from '../../types/models'; import type { NewRuleEntity, RuleEntity } from '../../types/models/rule'; import type { EmptyObject, StripNever } from '../../types/util'; @@ -121,7 +122,7 @@ type FinanceModals = { month: string; }; - 'schedule-edit': { id: string } | null; + 'schedule-edit': { id: string; transaction?: TransactionEntity } | null; 'schedule-link': { transactionIds: string[] } | null; diff --git a/packages/loot-core/src/server/accounts/transactions.ts b/packages/loot-core/src/server/accounts/transactions.ts index 5d6001eb5875077446b9b5c32a215c46cce71523..08fb702a7a1948be20faecf6a614f60dd6596257 100644 --- a/packages/loot-core/src/server/accounts/transactions.ts +++ b/packages/loot-core/src/server/accounts/transactions.ts @@ -1,6 +1,6 @@ // @ts-strict-ignore import * as connection from '../../platform/server/connection'; -import { TransactionEntity } from '../../types/models'; +import { NewTransactionEntity, TransactionEntity } from '../../types/models'; import * as db from '../db'; import { incrFetch, whereIn } from '../db/util'; import { batchMessages } from '../sync'; @@ -43,14 +43,9 @@ export async function batchUpdateTransactions({ detectOrphanPayees = true, runTransfers = true, }: { - added?: Array<{ id: string; payee: unknown; category: unknown }>; - deleted?: Array<{ id: string; payee: unknown }>; - updated?: Array<{ - id: string; - payee?: unknown; - account?: unknown; - category?: unknown; - }>; + added?: Array<Partial<NewTransactionEntity | TransactionEntity>>; + deleted?: Array<Partial<NewTransactionEntity | TransactionEntity>>; + updated?: Array<Partial<NewTransactionEntity | TransactionEntity>>; learnCategories?: boolean; detectOrphanPayees?: boolean; runTransfers?: boolean; diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 70617167357c45de4028c7ca8868ebd5d69dce4c..3c1be8ef49ed591b0d31c3b10cf2c8a7ea0d43f7 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -112,8 +112,7 @@ handlers['transactions-batch-update'] = mutator(async function ({ learnCategories, }); - // Return all data updates to the frontend - return result.updated; + return result; }); }); diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index 96c8a7c3aa246ffa0fd1a8314ad7ae0f919671d9..773d59fac70c2f1d6c897f1d0d1fb25880bdfa12 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -66,6 +66,22 @@ export function makeChild<T extends GenericTransactionEntity>( } as unknown as T; } +function makeNonChild<T extends GenericTransactionEntity>( + parent: T, + data: object, +) { + return { + amount: 0, + ...data, + cleared: parent.cleared != null ? parent.cleared : null, + reconciled: parent.reconciled != null ? parent.reconciled : null, + sort_order: parent.sort_order || null, + starting_balance_flag: null, + is_child: false, + parent_id: null, + } as unknown as T; +} + export function recalculateSplit(trans: TransactionEntity) { // Calculate the new total of split transactions and make sure // that it equals the parent amount @@ -314,3 +330,47 @@ export function realizeTempTransactions(transactions: TransactionEntity[]) { })), ]; } + +export function makeAsNonChildTransactions( + childTransactionsToUpdate: TransactionEntity[], + transactions: TransactionEntity[], +) { + const [parentTransaction, ...childTransactions] = transactions; + const newNonChildTransactions = childTransactionsToUpdate.map(t => + makeNonChild(parentTransaction, t), + ); + + const remainingChildTransactions = childTransactions.filter( + t => + !newNonChildTransactions.some(updatedTrans => updatedTrans.id === t.id), + ); + + const nonChildTransactionsToUpdate = + remainingChildTransactions.length === 1 + ? [ + ...newNonChildTransactions, + makeNonChild(parentTransaction, remainingChildTransactions[0]), + ] + : newNonChildTransactions; + + const deleteParentTransaction = remainingChildTransactions.length <= 1; + + const updatedParentTransaction = { + ...parentTransaction, + ...(!deleteParentTransaction + ? { + amount: remainingChildTransactions + .map(t => t.amount) + .reduce((total, amount) => total + amount, 0), + } + : {}), + }; + + return { + updated: [ + ...(!deleteParentTransaction ? [updatedParentTransaction] : []), + ...nonChildTransactionsToUpdate, + ], + deleted: [...(deleteParentTransaction ? [updatedParentTransaction] : [])], + }; +} diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 0cf51bc85bc1ae4fa05f9624cfedbac257dc7abd..50736f01325d8b5e4a9f2cb4d2002562f81fc34f 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -32,7 +32,7 @@ export interface ServerHandlers { Parameters<typeof batchUpdateTransactions>[0], 'detectOrphanPayees' >, - ) => Promise<Awaited<ReturnType<typeof batchUpdateTransactions>>['updated']>; + ) => Promise<Awaited<ReturnType<typeof batchUpdateTransactions>>>; 'transaction-add': (transaction) => Promise<EmptyObject>; diff --git a/upcoming-release-notes/2805.md b/upcoming-release-notes/2805.md new file mode 100644 index 0000000000000000000000000000000000000000..52655dc81d9da511455a21275553a58175c9fe25 --- /dev/null +++ b/upcoming-release-notes/2805.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Make multiple transactions as a split transaction or separate a split transaction into multiple individual ones.