Compare commits

...

13 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
2ededcd854 Implement saving 2025-11-04 11:17:23 -08:00
Joel Jeremy Marquez
fbc6a42662 Change amount input type to number 2025-11-03 14:52:32 -08:00
Joel Jeremy Marquez
247b0d970a Smaller amount input 2025-11-03 14:10:56 -08:00
Joel Jeremy Marquez
b29a12799c Style updates 2025-11-03 14:07:15 -08:00
Joel Jeremy Marquez
b3c62fd69d New mobile transaction page (partial) 2025-11-03 13:56:29 -08:00
Matt Fiddaman
cbac6116d4 fix rerender issue with formula card (#6058)
* fix infinite rerender

* note

* remove React import
2025-11-03 01:30:32 +00:00
Michael Clark
e83cfba357 Remove plugin worker temporarily (#6052)
* remove plugin worker temporarily

* releas enotes

* clarifying comment

* remove plugin worker temporarily

* releas enotes

* clarifying comment
2025-11-02 10:18:49 +00:00
Matiss Janis Aboltins
0cac66b203 Remove isGlobal preference functionality (#6049) 2025-11-01 14:18:08 +00:00
Michael Clark
7983ee45e1 :electron: New appx icons (#6043)
* new appx icons

* updates the windows store appx icons to the new style
2025-10-31 21:45:23 +00:00
Michael Clark
844cd3433a :electron: Flathub MetaInfo (#6033)
* updates to flathub metainfo

* update to summary

* release notes

* omit :light

* Update upcoming-release-notes/6033.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* actual budget

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-10-30 17:15:47 +00:00
dbequeaith
ae6bea2b15 Import qfx safari mobile (#6020)
* Supports selecting qfx files on safari mobile

Fixes #4283

accept explicit MIME types associated with qfx files

* generated release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-28 14:20:58 +00:00
Michael Clark
37481535e7 ☁️ Fix server sync file download when server-files are in .config (#6010)
* fix server sync file download when server-files are in .config directory on linux

* extra security

* release notes

* putting it back after testing

* also accounting for directories

* derp
2025-10-27 20:11:40 +00:00
Matiss Janis Aboltins
45a4f0a40d Add sort_by field to custom reports (#6005) 2025-10-27 19:59:53 +00:00
124 changed files with 1239 additions and 102 deletions

View File

@@ -31,3 +31,6 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

View File

@@ -100,6 +100,19 @@ global.Actual = {
restartElectronServer: () => {},
openFileDialog: async ({ filters = [] }) => {
const FILE_ACCEPT_OVERRIDES = {
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
qfx: [
'application/vnd.intu.qfx',
'application/x-qfx',
'application/qfx',
'application/ofx',
'application/x-ofx',
'application/octet-stream',
'com.intuit.qfx',
],
};
return new Promise(resolve => {
let createdElement = false;
// Attempt to reuse an already-created file input.
@@ -117,7 +130,15 @@ global.Actual = {
const filter = filters.find(filter => filter.extensions);
if (filter) {
input.accept = filter.extensions.map(ext => '.' + ext).join(',');
input.accept = filter.extensions
.flatMap(ext => {
const normalizedExt = ext.startsWith('.')
? ext.toLowerCase()
: `.${ext.toLowerCase()}`;
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
return [normalizedExt, ...overrides];
})
.join(',');
}
input.style.position = 'absolute';

View File

@@ -16,6 +16,7 @@ import { GlobalKeys } from './GlobalKeys';
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
import { MobileNavTabs } from './mobile/MobileNavTabs';
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
import { TransactionFormPage } from './mobile/transactions/TransactionFormPage';
import { Notifications } from './Notifications';
import { Reports } from './reports';
import { LoadingIndicator } from './reports/LoadingIndicator';
@@ -316,7 +317,8 @@ export function FinancesApp() {
path="/transactions/:transactionId"
element={
<WideNotSupported>
<TransactionEdit />
{/* <TransactionEdit /> */}
<TransactionFormPage />
</WideNotSupported>
}
/>

View File

@@ -1357,11 +1357,11 @@ class AccountInternal extends PureComponent<
onSetTransfer = async (ids: string[]) => {
this.setState({ workingHard: true });
await this.props.onSetTransfer(
await this.props.onSetTransfer({
ids,
this.props.payees,
this.refetchTransactions,
);
payees: this.props.payees,
onSuccess: this.refetchTransactions,
});
};
onConditionsOpChange = (value: 'and' | 'or') => {

View File

@@ -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}
/>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -632,22 +632,27 @@ function SelectedTransactionsFloatingActionBar({
},
});
} else if (type === 'transfer') {
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
showUndoNotification({
message: t(
'Successfully marked {{count}} transactions as transfer.',
{
count: ids.length,
},
),
}),
);
onSetTransfer?.({
ids: selectedTransactionsArray,
payees,
onSuccess: ids =>
showUndoNotification({
message: t(
'Successfully marked {{count}} transactions as transfer.',
{
count: ids.length,
},
),
}),
});
} else if (type === 'merge') {
onMerge?.(selectedTransactionsArray, () =>
showUndoNotification({
message: t('Successfully merged transactions'),
}),
);
onMerge?.({
ids: selectedTransactionsArray,
onSuccess: () =>
showUndoNotification({
message: t('Successfully merged transactions'),
}),
});
}
setIsMoreOptionsMenuOpen(false);
}}

View File

@@ -8,9 +8,9 @@ import {
type GetPrettyPayeeProps = {
t: ReturnType<typeof useTranslation>['t'];
transaction?: TransactionEntity;
payee?: PayeeEntity;
transferAccount?: AccountEntity;
transaction?: TransactionEntity | null;
payee?: PayeeEntity | null;
transferAccount?: AccountEntity | null;
};
export function getPrettyPayee({

View File

@@ -2,6 +2,7 @@ import {
useState,
useRef,
useCallback,
useMemo,
Suspense,
lazy,
type ChangeEvent,
@@ -92,12 +93,8 @@ function FormulaInner({ widget }: FormulaInnerProps) {
error,
} = useFormulaExecution(formula, queriesRef.current, queriesVersion);
// Execute color formula with access to main result via named expression
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
queriesRef.current,
queriesVersion,
{
const colorVariables = useMemo(
() => ({
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
@@ -106,7 +103,14 @@ function FormulaInner({ widget }: FormulaInnerProps) {
},
{} as Record<string, string>,
),
},
}),
[result, themeColors],
);
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
queriesRef.current,
queriesVersion,
colorVariables,
);
const handleQueriesChange = useCallback(
@@ -387,16 +391,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
<Suspense fallback={<div style={{ height: 32 }} />}>
<FormulaEditor
value={colorFormula}
variables={{
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
acc[`theme_${key}`] = value;
return acc;
},
{} as Record<string, string>,
),
}}
variables={colorVariables}
onChange={setColorFormula}
mode="query"
queries={queriesRef.current}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { View } from '@actual-app/components/view';
@@ -42,12 +42,8 @@ export function FormulaCard({
meta?.queriesVersion,
);
// Execute color formula with access to main result via named expression
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
meta?.queries || {},
meta?.queriesVersion,
{
const colorVariables = useMemo(
() => ({
RESULT: result ?? 0,
...Object.entries(themeColors).reduce(
(acc, [key, value]) => {
@@ -56,7 +52,14 @@ export function FormulaCard({
},
{} as Record<string, string>,
),
},
}),
[result, themeColors],
);
const { result: colorResult, error: colorError } = useFormulaExecution(
colorFormula,
meta?.queries || {},
meta?.queriesVersion,
colorVariables,
);
// Determine the custom color from color formula result

View File

@@ -83,7 +83,7 @@ function GlobalFeatureToggle({
error,
children,
}: GlobalFeatureToggleProps) {
const [enabled, setEnabled] = useSyncedPref(prefName, { isGlobal: true });
const [enabled, setEnabled] = useSyncedPref(prefName);
return (
<label style={{ display: 'flex' }}>

View File

@@ -11,7 +11,6 @@ type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
export function useSyncedPref<K extends keyof SyncedPrefs>(
prefName: K,
options?: { isGlobal?: boolean },
): [SyncedPrefs[K], SetSyncedPrefAction<K>] {
const dispatch = useDispatch();
const setPref = useCallback<SetSyncedPrefAction<K>>(
@@ -19,11 +18,10 @@ export function useSyncedPref<K extends keyof SyncedPrefs>(
dispatch(
saveSyncedPrefs({
prefs: { [prefName]: value },
isGlobal: options?.isGlobal,
}),
);
},
[prefName, dispatch, options?.isGlobal],
[prefName, dispatch],
);
const pref = useSelector(state => state.prefs.synced[prefName]);

View File

@@ -5,6 +5,7 @@ import * as monthUtils from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import {
deleteTransaction,
isTemporaryId,
realizeTempTransactions,
ungroupTransaction,
ungroupTransactions,
@@ -58,6 +59,22 @@ type BatchUnlinkScheduleProps = {
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() {
const dispatch = useDispatch();
const { t } = useTranslation();
@@ -456,11 +473,11 @@ export function useTransactionBatchActions() {
}
};
const onSetTransfer = async (
ids: string[],
payees: PayeeEntity[],
onSuccess: (ids: string[]) => void,
) => {
const onSetTransfer = async ({
ids,
payees,
onSuccess,
}: SetTransferProps) => {
const onConfirmTransfer = async (ids: string[]) => {
const { data: transactions } = await aqlQuery(
q('transactions')
@@ -506,12 +523,60 @@ export function useTransactionBatchActions() {
);
};
const onMerge = async (ids: string[], onSuccess: () => void) => {
const onMerge = async ({ ids, onSuccess }: MergeProps) => {
await send(
'transactions-merge',
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 {
@@ -522,5 +587,6 @@ export function useTransactionBatchActions() {
onBatchUnlinkSchedule,
onSetTransfer,
onMerge,
onBatchSave,
};
}

View File

@@ -233,7 +233,7 @@ export type Modal =
| {
name: 'payee-autocomplete';
options: {
onSelect: (payeeId: string) => void;
onSelect: (payeeId: string, payeeName: string) => void;
onClose?: () => void;
};
}

View File

@@ -108,18 +108,16 @@ export const saveGlobalPrefs = createAppAsyncThunk(
type SaveSyncedPrefsPayload = {
prefs: SyncedPrefs;
isGlobal?: boolean;
};
export const saveSyncedPrefs = createAppAsyncThunk(
`${sliceName}/saveSyncedPrefs`,
async ({ prefs, isGlobal }: SaveSyncedPrefsPayload, { dispatch }) => {
async ({ prefs }: SaveSyncedPrefsPayload, { dispatch }) => {
await Promise.all(
Object.entries(prefs).map(([prefName, value]) =>
send('preferences/save', {
id: prefName as keyof SyncedPrefs,
value,
isGlobal,
}),
),
);

View File

@@ -159,22 +159,23 @@ export default defineConfig(async ({ mode }) => {
? undefined
: VitePWA({
registerType: 'prompt',
strategies: 'injectManifest',
srcDir: 'service-worker',
filename: 'plugin-sw.js',
manifest: {
name: 'Actual',
short_name: 'Actual',
description: 'A local-first personal finance tool',
theme_color: '#8812E1',
background_color: '#8812E1',
display: 'standalone',
start_url: './',
},
injectManifest: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
swSrc: `service-worker/plugin-sw.js`,
},
// TODO: The plugin worker build is currently disabled due to issues with offline support. Fix this
// strategies: 'injectManifest',
// srcDir: 'service-worker',
// filename: 'plugin-sw.js',
// manifest: {
// name: 'Actual',
// short_name: 'Actual',
// description: 'A local-first personal finance tool',
// theme_color: '#8812E1',
// background_color: '#8812E1',
// display: 'standalone',
// start_url: './',
// },
// injectManifest: {
// maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10MB
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
enabled: true, // We need service worker in dev mode to work with plugins
type: 'module',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 B

After

Width:  |  Height:  |  Size: 779 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Some files were not shown because too many files have changed in this diff Show More