mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Compare commits
5 Commits
feat/auto-
...
Transactio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ededcd854 | ||
|
|
fbc6a42662 | ||
|
|
247b0d970a | ||
|
|
b29a12799c | ||
|
|
b3c62fd69d |
@@ -16,6 +16,7 @@ import { GlobalKeys } from './GlobalKeys';
|
|||||||
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
|
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
|
||||||
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
||||||
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
|
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
|
||||||
|
import { TransactionFormPage } from './mobile/transactions/TransactionFormPage';
|
||||||
import { Notifications } from './Notifications';
|
import { Notifications } from './Notifications';
|
||||||
import { Reports } from './reports';
|
import { Reports } from './reports';
|
||||||
import { LoadingIndicator } from './reports/LoadingIndicator';
|
import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||||
@@ -316,7 +317,8 @@ export function FinancesApp() {
|
|||||||
path="/transactions/:transactionId"
|
path="/transactions/:transactionId"
|
||||||
element={
|
element={
|
||||||
<WideNotSupported>
|
<WideNotSupported>
|
||||||
<TransactionEdit />
|
{/* <TransactionEdit /> */}
|
||||||
|
<TransactionFormPage />
|
||||||
</WideNotSupported>
|
</WideNotSupported>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1357,11 +1357,11 @@ class AccountInternal extends PureComponent<
|
|||||||
|
|
||||||
onSetTransfer = async (ids: string[]) => {
|
onSetTransfer = async (ids: string[]) => {
|
||||||
this.setState({ workingHard: true });
|
this.setState({ workingHard: true });
|
||||||
await this.props.onSetTransfer(
|
await this.props.onSetTransfer({
|
||||||
ids,
|
ids,
|
||||||
this.props.payees,
|
payees: this.props.payees,
|
||||||
this.refetchTransactions,
|
onSuccess: this.refetchTransactions,
|
||||||
);
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onConditionsOpChange = (value: 'and' | 'or') => {
|
onConditionsOpChange = (value: 'and' | 'or') => {
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
import {
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
type ComponentProps,
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useReducer,
|
||||||
|
type Dispatch,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
} from 'react';
|
||||||
|
import { Form } from 'react-aria-components';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '@actual-app/components/button';
|
||||||
|
import { SvgCheveronRight } from '@actual-app/components/icons/v1';
|
||||||
|
import { Input } from '@actual-app/components/input';
|
||||||
|
import { Label } from '@actual-app/components/label';
|
||||||
|
import { styles } from '@actual-app/components/styles';
|
||||||
|
import { Text } from '@actual-app/components/text';
|
||||||
|
import { theme } from '@actual-app/components/theme';
|
||||||
|
import { Toggle } from '@actual-app/components/toggle';
|
||||||
|
import { View } from '@actual-app/components/view';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
|
||||||
|
import { currentDay } from 'loot-core/shared/months';
|
||||||
|
import {
|
||||||
|
appendDecimals,
|
||||||
|
currencyToInteger,
|
||||||
|
groupById,
|
||||||
|
type IntegerAmount,
|
||||||
|
integerToCurrency,
|
||||||
|
} from 'loot-core/shared/util';
|
||||||
|
import { type TransactionEntity } from 'loot-core/types/models';
|
||||||
|
|
||||||
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||||
|
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||||
|
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||||
|
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||||
|
import { useTransactionBatchActions } from '@desktop-client/hooks/useTransactionBatchActions';
|
||||||
|
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||||
|
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
type TransactionFormState = {
|
||||||
|
transactions: Record<
|
||||||
|
TransactionEntity['id'],
|
||||||
|
Pick<
|
||||||
|
TransactionEntity,
|
||||||
|
| 'id'
|
||||||
|
| 'amount'
|
||||||
|
| 'payee'
|
||||||
|
| 'category'
|
||||||
|
| 'account'
|
||||||
|
| 'date'
|
||||||
|
| 'cleared'
|
||||||
|
| 'notes'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
focusedTransaction: TransactionEntity['id'] | null;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransactionFormActions =
|
||||||
|
| {
|
||||||
|
type: 'set-amount';
|
||||||
|
id: TransactionEntity['id'];
|
||||||
|
amount: TransactionEntity['amount'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-payee';
|
||||||
|
id: TransactionEntity['id'];
|
||||||
|
payee: TransactionEntity['payee'] | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-category';
|
||||||
|
id: TransactionEntity['id'];
|
||||||
|
category: TransactionEntity['category'] | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-notes';
|
||||||
|
id: TransactionEntity['id'];
|
||||||
|
notes: NonNullable<TransactionEntity['notes']>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-account';
|
||||||
|
account: TransactionEntity['account'] | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-date';
|
||||||
|
date: NonNullable<TransactionEntity['date']>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'set-cleared';
|
||||||
|
cleared: NonNullable<TransactionEntity['cleared']>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'split';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'add-split';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'focus';
|
||||||
|
id: TransactionEntity['id'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'reset';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'submit';
|
||||||
|
};
|
||||||
|
|
||||||
|
const TransactionFormStateContext = createContext<TransactionFormState>({
|
||||||
|
transactions: {},
|
||||||
|
focusedTransaction: null,
|
||||||
|
isSubmitting: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const TransactionFormDispatchContext =
|
||||||
|
createContext<Dispatch<TransactionFormActions> | null>(null);
|
||||||
|
|
||||||
|
type TransactionFormProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
transactions: readonly TransactionEntity[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionFormProvider({
|
||||||
|
children,
|
||||||
|
transactions,
|
||||||
|
}: TransactionFormProviderProps) {
|
||||||
|
const unmodifiedTransactions = useMemo(() => {
|
||||||
|
return transactions.reduce(
|
||||||
|
(acc, transaction) => {
|
||||||
|
acc[transaction.id] = {
|
||||||
|
id: transaction.id,
|
||||||
|
amount: transaction.amount,
|
||||||
|
payee: transaction.payee,
|
||||||
|
category: transaction.category,
|
||||||
|
account: transaction.account,
|
||||||
|
date: transaction.date,
|
||||||
|
cleared: transaction.cleared,
|
||||||
|
notes: transaction.notes,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as TransactionFormState['transactions'],
|
||||||
|
);
|
||||||
|
}, [transactions]);
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(
|
||||||
|
(state: TransactionFormState, action: TransactionFormActions) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'set-amount':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: {
|
||||||
|
...state.transactions,
|
||||||
|
[action.id]: {
|
||||||
|
...state.transactions[action.id],
|
||||||
|
amount: action.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'set-payee':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: {
|
||||||
|
...state.transactions,
|
||||||
|
[action.id]: {
|
||||||
|
...state.transactions[action.id],
|
||||||
|
payee: action.payee,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'set-category':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: {
|
||||||
|
...state.transactions,
|
||||||
|
[action.id]: {
|
||||||
|
...state.transactions[action.id],
|
||||||
|
category: action.category,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'set-notes':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: {
|
||||||
|
...state.transactions,
|
||||||
|
[action.id]: {
|
||||||
|
...state.transactions[action.id],
|
||||||
|
notes: action.notes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'set-account':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: Object.keys(state.transactions).reduce(
|
||||||
|
(acc, id) => ({
|
||||||
|
...acc,
|
||||||
|
[id]: {
|
||||||
|
...state.transactions[id],
|
||||||
|
account: action.account,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{} as TransactionFormState['transactions'],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'set-date':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: Object.keys(state.transactions).reduce(
|
||||||
|
(acc, id) => ({
|
||||||
|
...acc,
|
||||||
|
[id]: {
|
||||||
|
...state.transactions[id],
|
||||||
|
date: action.date,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{} as TransactionFormState['transactions'],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'set-cleared':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: Object.keys(state.transactions).reduce(
|
||||||
|
(acc, id) => ({
|
||||||
|
...acc,
|
||||||
|
[id]: {
|
||||||
|
...state.transactions[id],
|
||||||
|
cleared: action.cleared,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{} as TransactionFormState['transactions'],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'focus':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
focusedTransaction: action.id,
|
||||||
|
};
|
||||||
|
case 'reset':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
transactions: unmodifiedTransactions,
|
||||||
|
isSubmitting: false,
|
||||||
|
};
|
||||||
|
case 'submit':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isSubmitting: true,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactions: unmodifiedTransactions,
|
||||||
|
focusedTransaction: null,
|
||||||
|
isSubmitting: false,
|
||||||
|
} as TransactionFormState,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
|
}, [unmodifiedTransactions]);
|
||||||
|
|
||||||
|
const { onBatchSave } = useTransactionBatchActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function saveTransactions() {
|
||||||
|
const transactionsToSave = Object.values(state.transactions);
|
||||||
|
await onBatchSave({
|
||||||
|
transactions: transactionsToSave,
|
||||||
|
onSuccess: () => {
|
||||||
|
dispatch({ type: 'reset' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.isSubmitting) {
|
||||||
|
saveTransactions().catch(console.error);
|
||||||
|
}
|
||||||
|
}, [state.isSubmitting, state.transactions, onBatchSave]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransactionFormStateContext.Provider value={state}>
|
||||||
|
<TransactionFormDispatchContext.Provider value={dispatch}>
|
||||||
|
{children}
|
||||||
|
</TransactionFormDispatchContext.Provider>
|
||||||
|
</TransactionFormStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionFormState() {
|
||||||
|
const context = useContext(TransactionFormStateContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error(
|
||||||
|
'useTransactionFormState must be used within a TransactionFormProvider',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransactionFormDispatch() {
|
||||||
|
const context = useContext(TransactionFormDispatchContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error(
|
||||||
|
'useTransactionFormDispatch must be used within a TransactionFormProvider',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionFormProps = {
|
||||||
|
transactions: ReadonlyArray<TransactionEntity>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransactionForm({ transactions }: TransactionFormProps) {
|
||||||
|
const [transaction] = transactions;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const lastTransaction = useSelector(
|
||||||
|
state => state.transactions.lastTransaction,
|
||||||
|
);
|
||||||
|
const payees = usePayees();
|
||||||
|
const payeesById = useMemo(() => groupById(payees), [payees]);
|
||||||
|
const getPayeeName = useCallback(
|
||||||
|
(payeeId: TransactionEntity['payee']) => {
|
||||||
|
if (!payeeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payeesById[payeeId]?.name ?? null;
|
||||||
|
},
|
||||||
|
[payeesById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { list: categories } = useCategories();
|
||||||
|
const categoriesById = useMemo(() => groupById(categories), [categories]);
|
||||||
|
const getCategoryName = useCallback(
|
||||||
|
(categoryId: TransactionEntity['category']) => {
|
||||||
|
if (!categoryId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return categoriesById[categoryId]?.name ?? null;
|
||||||
|
},
|
||||||
|
[categoriesById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const accounts = useAccounts();
|
||||||
|
const accountsById = useMemo(() => groupById(accounts), [accounts]);
|
||||||
|
const getAccountName = useCallback(
|
||||||
|
(accountId: TransactionEntity['account']) => {
|
||||||
|
if (!accountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return accountsById[accountId]?.name ?? null;
|
||||||
|
},
|
||||||
|
[accountsById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionFormState = useTransactionFormState();
|
||||||
|
|
||||||
|
const getTransactionState = useCallback(
|
||||||
|
(id: TransactionEntity['id']) => {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return transactionFormState.transactions[id] ?? null;
|
||||||
|
},
|
||||||
|
[transactionFormState.transactions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionFormDispatch = useTransactionFormDispatch();
|
||||||
|
|
||||||
|
const onSelectPayee = (id: TransactionEntity['id']) => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'payee-autocomplete',
|
||||||
|
options: {
|
||||||
|
onSelect: payeeId =>
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'set-payee',
|
||||||
|
id,
|
||||||
|
payee: payeeId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectCategory = (id: TransactionEntity['id']) => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'category-autocomplete',
|
||||||
|
options: {
|
||||||
|
onSelect: categoryId =>
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'set-category',
|
||||||
|
id,
|
||||||
|
category: categoryId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeNotes = (id: TransactionEntity['id'], notes: string) => {
|
||||||
|
transactionFormDispatch({ type: 'set-notes', id, notes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectAccount = () => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'account-autocomplete',
|
||||||
|
options: {
|
||||||
|
onSelect: accountId =>
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'set-account',
|
||||||
|
account: accountId,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSelectDate = (date: string) => {
|
||||||
|
transactionFormDispatch({ type: 'set-date', date });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateAmount = (
|
||||||
|
id: TransactionEntity['id'],
|
||||||
|
amount: IntegerAmount,
|
||||||
|
) => {
|
||||||
|
console.log('onUpdateAmount', amount);
|
||||||
|
transactionFormDispatch({ type: 'set-amount', id, amount });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleCleared = (isCleared: boolean) => {
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'set-cleared',
|
||||||
|
cleared: isCleared,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form data-testid="transaction-form">
|
||||||
|
<View style={{ padding: styles.mobileEditingPadding, gap: 40 }}>
|
||||||
|
<View>
|
||||||
|
<TransactionAmount
|
||||||
|
transaction={transaction}
|
||||||
|
onUpdate={amount => onUpdateAmount(transaction.id, amount)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={css({
|
||||||
|
gap: 20,
|
||||||
|
'& .view': {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
'& button,input': {
|
||||||
|
height: styles.mobileMinHeight,
|
||||||
|
textAlign: 'center',
|
||||||
|
...styles.mediumText,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Payee')} />
|
||||||
|
<Button
|
||||||
|
variant="bare"
|
||||||
|
onClick={() => onSelectPayee(transaction.id)}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
{getPayeeName(getTransactionState(transaction.id)?.payee)}
|
||||||
|
<SvgCheveronRight
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.mobileHeaderTextSubdued,
|
||||||
|
}}
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Category')} />
|
||||||
|
<Button
|
||||||
|
variant="bare"
|
||||||
|
onClick={() => onSelectCategory(transaction.id)}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
{getCategoryName(getTransactionState(transaction.id)?.category)}
|
||||||
|
<SvgCheveronRight
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.mobileHeaderTextSubdued,
|
||||||
|
}}
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Account')} />
|
||||||
|
<Button variant="bare" onClick={onSelectAccount}>
|
||||||
|
<View>
|
||||||
|
{getAccountName(getTransactionState(transaction.id)?.account)}
|
||||||
|
<SvgCheveronRight
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
color: theme.mobileHeaderTextSubdued,
|
||||||
|
}}
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Date')} />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={getTransactionState(transaction.id)?.date ?? currentDay()}
|
||||||
|
onChangeValue={onSelectDate}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Cleared')} />
|
||||||
|
<FormToggle
|
||||||
|
id="Cleared"
|
||||||
|
isOn={getTransactionState(transaction.id)?.cleared ?? false}
|
||||||
|
onToggle={onToggleCleared}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Label title={t('Notes')} />
|
||||||
|
<Input
|
||||||
|
value={getTransactionState(transaction.id)?.notes ?? ''}
|
||||||
|
onChangeValue={notes => onChangeNotes(transaction.id, notes)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransactionAmountProps = {
|
||||||
|
transaction: TransactionEntity;
|
||||||
|
onUpdate: (amount: IntegerAmount) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TransactionAmount({ transaction, onUpdate }: TransactionAmountProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const format = useFormat();
|
||||||
|
const [value, setValue] = useState(format(transaction.amount, 'financial'));
|
||||||
|
|
||||||
|
const onChangeValue = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setValue(appendDecimals(value));
|
||||||
|
},
|
||||||
|
[setValue],
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onUpdate = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const parsedAmount = currencyToInteger(value) || 0;
|
||||||
|
setValue(
|
||||||
|
parsedAmount !== 0
|
||||||
|
? format(parsedAmount, 'financial')
|
||||||
|
: format(0, 'financial'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsedAmount !== transaction.amount) {
|
||||||
|
onUpdate(parsedAmount);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[format],
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountInteger = value ? (currencyToInteger(value) ?? 0) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', gap: 10 }}>
|
||||||
|
<Label
|
||||||
|
style={{ textAlign: 'center', ...styles.mediumText }}
|
||||||
|
title={t('Amount')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
style={{
|
||||||
|
height: '15vh',
|
||||||
|
width: '100vw',
|
||||||
|
textAlign: 'center',
|
||||||
|
...styles.veryLargeText,
|
||||||
|
color: amountInteger > 0 ? theme.noticeText : theme.errorText,
|
||||||
|
}}
|
||||||
|
value={value || ''}
|
||||||
|
onChangeValue={onChangeValue}
|
||||||
|
onUpdate={_onUpdate}
|
||||||
|
/>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Text style={styles.largeText}>-</Text>
|
||||||
|
<FormToggle
|
||||||
|
id="TransactionAmountSign"
|
||||||
|
isOn={amountInteger > 0}
|
||||||
|
isDisabled={amountInteger === 0}
|
||||||
|
onToggle={() => _onUpdate(integerToCurrency(-amountInteger))}
|
||||||
|
/>
|
||||||
|
<Text style={styles.largeText}>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormToggleProps = ComponentProps<typeof Toggle>;
|
||||||
|
|
||||||
|
function FormToggle({ className, ...restProps }: FormToggleProps) {
|
||||||
|
return (
|
||||||
|
<Toggle
|
||||||
|
className={css({
|
||||||
|
'& [data-toggle-container]': {
|
||||||
|
width: 50,
|
||||||
|
height: 24,
|
||||||
|
},
|
||||||
|
'& [data-toggle]': {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
type Ref,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { Button } from '@actual-app/components/button';
|
||||||
|
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||||
|
import { SvgSplit } from '@actual-app/components/icons/v0';
|
||||||
|
import { SvgAdd, SvgPiggyBank } from '@actual-app/components/icons/v1';
|
||||||
|
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
|
||||||
|
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 { q } from 'loot-core/shared/query';
|
||||||
|
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
||||||
|
import { type TransactionEntity } from 'loot-core/types/models';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TransactionForm,
|
||||||
|
TransactionFormProvider,
|
||||||
|
useTransactionFormDispatch,
|
||||||
|
useTransactionFormState,
|
||||||
|
} from './TransactionForm';
|
||||||
|
|
||||||
|
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||||
|
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
|
||||||
|
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||||
|
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||||
|
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||||
|
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||||
|
import { useTransactions } from '@desktop-client/hooks/useTransactions';
|
||||||
|
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||||
|
import { useDispatch } from '@desktop-client/redux';
|
||||||
|
|
||||||
|
export function TransactionFormPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { transactionId } = useParams();
|
||||||
|
|
||||||
|
const accounts = useAccounts();
|
||||||
|
const accountsById = useMemo(() => groupById(accounts), [accounts]);
|
||||||
|
const payees = usePayees();
|
||||||
|
const payeesById = useMemo(() => groupById(payees), [payees]);
|
||||||
|
|
||||||
|
// const getAccount = useCallback(
|
||||||
|
// trans => {
|
||||||
|
// return trans?.account && accountsById?.[trans.account];
|
||||||
|
// },
|
||||||
|
// [accountsById],
|
||||||
|
// );
|
||||||
|
|
||||||
|
const getPayee = useCallback(
|
||||||
|
(trans: TransactionEntity) => {
|
||||||
|
return trans?.payee ? payeesById?.[trans.payee] : null;
|
||||||
|
},
|
||||||
|
[payeesById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTransferAccount = useCallback(
|
||||||
|
(trans: TransactionEntity) => {
|
||||||
|
const payee = trans && getPayee(trans);
|
||||||
|
return payee?.transfer_acct ? accountsById?.[payee.transfer_acct] : null;
|
||||||
|
},
|
||||||
|
[accountsById, getPayee],
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionsQuery = useMemo(
|
||||||
|
() =>
|
||||||
|
q('transactions')
|
||||||
|
.filter({ id: transactionId })
|
||||||
|
.select('*')
|
||||||
|
.options({ splits: 'all' }),
|
||||||
|
[transactionId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { transactions, isLoading } = useTransactions({
|
||||||
|
query: transactionsQuery,
|
||||||
|
});
|
||||||
|
const [transaction] = transactions;
|
||||||
|
|
||||||
|
const title = getPrettyPayee({
|
||||||
|
t,
|
||||||
|
transaction,
|
||||||
|
payee: getPayee(transaction),
|
||||||
|
transferAccount: getTransferAccount(transaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransactionFormProvider transactions={transactions}>
|
||||||
|
<Page
|
||||||
|
header={
|
||||||
|
<MobilePageHeader
|
||||||
|
title={
|
||||||
|
!transaction?.payee
|
||||||
|
? !transactionId
|
||||||
|
? t('New Transaction')
|
||||||
|
: t('Transaction')
|
||||||
|
: title
|
||||||
|
}
|
||||||
|
leftContent={<MobileBackButton />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={<Footer transactions={transactions} />}
|
||||||
|
padding={0}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<AnimatedLoading width={15} height={15} />
|
||||||
|
) : (
|
||||||
|
<TransactionForm transactions={transactions} />
|
||||||
|
)}
|
||||||
|
</Page>
|
||||||
|
</TransactionFormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FooterProps = {
|
||||||
|
transactions: ReadonlyArray<TransactionEntity>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Footer({ transactions }: FooterProps) {
|
||||||
|
const { transactionId } = useParams();
|
||||||
|
const isAdding = !transactionId;
|
||||||
|
const [transaction, ...childTransactions] = transactions;
|
||||||
|
const emptySplitTransaction = childTransactions.find(t => t.amount === 0);
|
||||||
|
|
||||||
|
const transactionFormDispatch = useTransactionFormDispatch();
|
||||||
|
|
||||||
|
const onClickRemainingSplit = () => {
|
||||||
|
if (!transaction) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childTransactions.length === 0) {
|
||||||
|
transactionFormDispatch({ type: 'split' });
|
||||||
|
} else {
|
||||||
|
if (!emptySplitTransaction) {
|
||||||
|
transactionFormDispatch({ type: 'add-split' });
|
||||||
|
} else {
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'focus',
|
||||||
|
id: emptySplitTransaction.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const onSelectAccount = () => {
|
||||||
|
dispatch(
|
||||||
|
pushModal({
|
||||||
|
modal: {
|
||||||
|
name: 'account-autocomplete',
|
||||||
|
options: {
|
||||||
|
onSelect: (accountId: string) => {
|
||||||
|
transactionFormDispatch({
|
||||||
|
type: 'set-account',
|
||||||
|
account: accountId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
transactionFormDispatch({ type: 'submit' });
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
data-testid="transaction-form-footer"
|
||||||
|
style={{
|
||||||
|
padding: `10px ${styles.mobileEditingPadding}px`,
|
||||||
|
backgroundColor: theme.tableHeaderBackground,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderColor: theme.tableBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{transaction?.error?.type === 'SplitTransactionError' ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
style={{ height: styles.mobileMinHeight }}
|
||||||
|
onPress={onClickRemainingSplit}
|
||||||
|
>
|
||||||
|
<SvgSplit width={17} height={17} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
...styles.text,
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!emptySplitTransaction ? (
|
||||||
|
<Trans>
|
||||||
|
Add new split -{' '}
|
||||||
|
{{
|
||||||
|
amount: integerToCurrency(
|
||||||
|
transaction.amount > 0
|
||||||
|
? transaction.error.difference
|
||||||
|
: -transaction.error.difference,
|
||||||
|
),
|
||||||
|
}}{' '}
|
||||||
|
left
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Amount left:{' '}
|
||||||
|
{{
|
||||||
|
amount: integerToCurrency(
|
||||||
|
transaction.amount > 0
|
||||||
|
? transaction.error.difference
|
||||||
|
: -transaction.error.difference,
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
) : !transaction?.account ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
style={{ height: styles.mobileMinHeight }}
|
||||||
|
onPress={onSelectAccount}
|
||||||
|
>
|
||||||
|
<SvgPiggyBank width={17} height={17} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
...styles.text,
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Select account</Trans>
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
) : isAdding ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
style={{ height: styles.mobileMinHeight }}
|
||||||
|
// onPress={onSubmit}
|
||||||
|
>
|
||||||
|
<SvgAdd width={17} height={17} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
...styles.text,
|
||||||
|
marginLeft: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Add transaction</Trans>
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
style={{ height: styles.mobileMinHeight }}
|
||||||
|
onPress={onSubmit}
|
||||||
|
>
|
||||||
|
<SvgPencilWriteAlternate width={16} height={16} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
...styles.text,
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Save changes</Trans>
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoSizingInput({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ({ ref }: { ref: Ref<HTMLInputElement> }) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const textRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (textRef.current && inputRef.current) {
|
||||||
|
const spanWidth = textRef.current.offsetWidth;
|
||||||
|
inputRef.current.style.width = `${spanWidth + 2}px`; // +2 for caret/padding
|
||||||
|
}
|
||||||
|
}, [inputRef.current?.value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children({ ref: inputRef })}
|
||||||
|
{/* Hidden span for measuring text width */}
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
visibility: 'hidden',
|
||||||
|
...styles.veryLargeText,
|
||||||
|
padding: '0 5px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inputRef.current?.value || ''}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -632,7 +632,10 @@ function SelectedTransactionsFloatingActionBar({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (type === 'transfer') {
|
} else if (type === 'transfer') {
|
||||||
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
|
onSetTransfer?.({
|
||||||
|
ids: selectedTransactionsArray,
|
||||||
|
payees,
|
||||||
|
onSuccess: ids =>
|
||||||
showUndoNotification({
|
showUndoNotification({
|
||||||
message: t(
|
message: t(
|
||||||
'Successfully marked {{count}} transactions as transfer.',
|
'Successfully marked {{count}} transactions as transfer.',
|
||||||
@@ -641,13 +644,15 @@ function SelectedTransactionsFloatingActionBar({
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
});
|
||||||
} else if (type === 'merge') {
|
} else if (type === 'merge') {
|
||||||
onMerge?.(selectedTransactionsArray, () =>
|
onMerge?.({
|
||||||
|
ids: selectedTransactionsArray,
|
||||||
|
onSuccess: () =>
|
||||||
showUndoNotification({
|
showUndoNotification({
|
||||||
message: t('Successfully merged transactions'),
|
message: t('Successfully merged transactions'),
|
||||||
}),
|
}),
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
setIsMoreOptionsMenuOpen(false);
|
setIsMoreOptionsMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
|
|
||||||
type GetPrettyPayeeProps = {
|
type GetPrettyPayeeProps = {
|
||||||
t: ReturnType<typeof useTranslation>['t'];
|
t: ReturnType<typeof useTranslation>['t'];
|
||||||
transaction?: TransactionEntity;
|
transaction?: TransactionEntity | null;
|
||||||
payee?: PayeeEntity;
|
payee?: PayeeEntity | null;
|
||||||
transferAccount?: AccountEntity;
|
transferAccount?: AccountEntity | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPrettyPayee({
|
export function getPrettyPayee({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as monthUtils from 'loot-core/shared/months';
|
|||||||
import { q } from 'loot-core/shared/query';
|
import { q } from 'loot-core/shared/query';
|
||||||
import {
|
import {
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
|
isTemporaryId,
|
||||||
realizeTempTransactions,
|
realizeTempTransactions,
|
||||||
ungroupTransaction,
|
ungroupTransaction,
|
||||||
ungroupTransactions,
|
ungroupTransactions,
|
||||||
@@ -58,6 +59,22 @@ type BatchUnlinkScheduleProps = {
|
|||||||
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SetTransferProps = {
|
||||||
|
ids: Array<TransactionEntity['id']>;
|
||||||
|
payees: PayeeEntity[];
|
||||||
|
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MergeProps = {
|
||||||
|
ids: Array<TransactionEntity['id']>;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchSaveProps = {
|
||||||
|
transactions: TransactionEntity[];
|
||||||
|
onSuccess?: (ids: Array<TransactionEntity['id']>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export function useTransactionBatchActions() {
|
export function useTransactionBatchActions() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -456,11 +473,11 @@ export function useTransactionBatchActions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSetTransfer = async (
|
const onSetTransfer = async ({
|
||||||
ids: string[],
|
ids,
|
||||||
payees: PayeeEntity[],
|
payees,
|
||||||
onSuccess: (ids: string[]) => void,
|
onSuccess,
|
||||||
) => {
|
}: SetTransferProps) => {
|
||||||
const onConfirmTransfer = async (ids: string[]) => {
|
const onConfirmTransfer = async (ids: string[]) => {
|
||||||
const { data: transactions } = await aqlQuery(
|
const { data: transactions } = await aqlQuery(
|
||||||
q('transactions')
|
q('transactions')
|
||||||
@@ -506,12 +523,60 @@ export function useTransactionBatchActions() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMerge = async (ids: string[], onSuccess: () => void) => {
|
const onMerge = async ({ ids, onSuccess }: MergeProps) => {
|
||||||
await send(
|
await send(
|
||||||
'transactions-merge',
|
'transactions-merge',
|
||||||
ids.map(id => ({ id })),
|
ids.map(id => ({ id })),
|
||||||
);
|
);
|
||||||
onSuccess();
|
onSuccess?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBatchSave = async ({ transactions, onSuccess }: BatchSaveProps) => {
|
||||||
|
if (transactions.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: unmodifiedTransactions } = await aqlQuery(
|
||||||
|
q('transactions')
|
||||||
|
.filter({ id: { $oneof: transactions.map(t => t.id) } })
|
||||||
|
.select('*'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changes: Diff<TransactionEntity> = {
|
||||||
|
added: [],
|
||||||
|
deleted: [],
|
||||||
|
updated: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let transactionsToSave = transactions.some(t => isTemporaryId(t.id))
|
||||||
|
? realizeTempTransactions(transactions)
|
||||||
|
: transactions;
|
||||||
|
|
||||||
|
transactionsToSave.forEach(transaction => {
|
||||||
|
const { diff } = updateTransaction(unmodifiedTransactions, 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
|
||||||
|
transactionsToSave = applyChanges<TransactionEntity>(
|
||||||
|
diff,
|
||||||
|
transactionsToSave,
|
||||||
|
);
|
||||||
|
|
||||||
|
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?.(transactionsToSave.map(t => t.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -522,5 +587,6 @@ export function useTransactionBatchActions() {
|
|||||||
onBatchUnlinkSchedule,
|
onBatchUnlinkSchedule,
|
||||||
onSetTransfer,
|
onSetTransfer,
|
||||||
onMerge,
|
onMerge,
|
||||||
|
onBatchSave,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export type Modal =
|
|||||||
| {
|
| {
|
||||||
name: 'payee-autocomplete';
|
name: 'payee-autocomplete';
|
||||||
options: {
|
options: {
|
||||||
onSelect: (payeeId: string) => void;
|
onSelect: (payeeId: string, payeeName: string) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user