Compare commits
8 Commits
Transactio
...
mobile/lin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19285ed594 | ||
|
|
62fa79effc | ||
|
|
89cea7ecc9 | ||
|
|
465a3a3fa5 | ||
|
|
d1d3e360f5 | ||
|
|
bfa8115452 | ||
|
|
7ebf687a96 | ||
|
|
fb2ec46981 |
@@ -4,7 +4,6 @@ import {
|
||||
isValidElement,
|
||||
type ReactElement,
|
||||
Ref,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
@@ -12,20 +11,15 @@ import {
|
||||
type InitialFocusProps<T extends HTMLElement> = {
|
||||
/**
|
||||
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
|
||||
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
|
||||
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
|
||||
*/
|
||||
children:
|
||||
| ReactElement<{ ref: Ref<T> }>
|
||||
| ((ref: RefObject<T | null>) => ReactElement);
|
||||
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* InitialFocus sets focus on its child element
|
||||
* when it mounts.
|
||||
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
|
||||
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
|
||||
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
|
||||
* @param {Object} props - The component props.
|
||||
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
|
||||
*/
|
||||
export function InitialFocus<T extends HTMLElement = HTMLElement>({
|
||||
children,
|
||||
|
||||
3
packages/desktop-client/.gitignore
vendored
@@ -31,6 +31,3 @@ public/*.wasm
|
||||
|
||||
# translations
|
||||
locale/
|
||||
|
||||
# service worker build output
|
||||
dev-dist
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
@@ -100,19 +100,6 @@ 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.
|
||||
@@ -130,15 +117,7 @@ global.Actual = {
|
||||
|
||||
const filter = filters.find(filter => filter.extensions);
|
||||
if (filter) {
|
||||
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.accept = filter.extensions.map(ext => '.' + ext).join(',');
|
||||
}
|
||||
|
||||
input.style.position = 'absolute';
|
||||
|
||||
@@ -16,7 +16,6 @@ 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';
|
||||
@@ -317,8 +316,7 @@ export function FinancesApp() {
|
||||
path="/transactions/:transactionId"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
{/* <TransactionEdit /> */}
|
||||
<TransactionFormPage />
|
||||
<TransactionEdit />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
payees: this.props.payees,
|
||||
onSuccess: this.refetchTransactions,
|
||||
});
|
||||
this.props.payees,
|
||||
this.refetchTransactions,
|
||||
);
|
||||
};
|
||||
|
||||
onConditionsOpChange = (value: 'and' | 'or') => {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -384,192 +383,192 @@ export function AccountHeader({
|
||||
<FilterButton onApply={onApplyFilter} />
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<SpaceBetween gap={10}>
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
ref={searchInput}
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
inputRef={searchInput}
|
||||
// Remove marginRight magically being added by Stack...
|
||||
// We need to refactor the Stack component
|
||||
style={{ marginRight: 0 }}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
<AnimatedLoading style={{ width: 16, height: 16 }} />
|
||||
</View>
|
||||
) : (
|
||||
<SelectedTransactionsButton
|
||||
getTransaction={id => transactions.find(t => t.id === id)}
|
||||
onShow={onShowTransactions}
|
||||
onDuplicate={onBatchDuplicate}
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onRunRules={onRunRules}
|
||||
onLinkSchedule={onBatchLinkSchedule}
|
||||
onUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
onMergeTransactions={onMergeTransactions}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
<AnimatedLoading style={{ width: 16, height: 16 }} />
|
||||
</View>
|
||||
) : (
|
||||
<SelectedTransactionsButton
|
||||
getTransaction={id => transactions.find(t => t.id === id)}
|
||||
onShow={onShowTransactions}
|
||||
onDuplicate={onBatchDuplicate}
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onRunRules={onRunRules}
|
||||
onLinkSchedule={onBatchLinkSchedule}
|
||||
onUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
onMergeTransactions={onMergeTransactions}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
{account && (
|
||||
<Tooltip
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
content={
|
||||
account?.last_reconciled
|
||||
? t(
|
||||
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
|
||||
{
|
||||
relativeTimeAgo: tsToRelativeTime(
|
||||
account.last_reconciled,
|
||||
locale,
|
||||
)}
|
||||
<View style={{ flex: '0 0 auto', marginLeft: 10 }}>
|
||||
{account && (
|
||||
<Tooltip
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
content={
|
||||
account?.last_reconciled
|
||||
? t(
|
||||
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
|
||||
{
|
||||
relativeTimeAgo: tsToRelativeTime(
|
||||
account.last_reconciled,
|
||||
locale,
|
||||
),
|
||||
absoluteDate: formatDate(
|
||||
new Date(
|
||||
parseInt(account.last_reconciled ?? '0', 10),
|
||||
),
|
||||
absoluteDate: formatDate(
|
||||
new Date(
|
||||
parseInt(account.last_reconciled ?? '0', 10),
|
||||
),
|
||||
dateFormat,
|
||||
{ locale },
|
||||
),
|
||||
},
|
||||
)
|
||||
: t('Not yet reconciled')
|
||||
}
|
||||
placement="top"
|
||||
triggerProps={{
|
||||
isDisabled: reconcileOpen,
|
||||
dateFormat,
|
||||
{ locale },
|
||||
),
|
||||
},
|
||||
)
|
||||
: t('Not yet reconciled')
|
||||
}
|
||||
placement="top"
|
||||
triggerProps={{
|
||||
isDisabled: reconcileOpen,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={reconcileRef}
|
||||
variant="bare"
|
||||
aria-label={t('Reconcile')}
|
||||
style={{ padding: 6 }}
|
||||
onPress={() => {
|
||||
setReconcileOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={reconcileRef}
|
||||
variant="bare"
|
||||
aria-label={t('Reconcile')}
|
||||
style={{ padding: 6 }}
|
||||
onPress={() => {
|
||||
setReconcileOpen(true);
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<SvgLockClosed width={14} height={14} />
|
||||
</View>
|
||||
</Button>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
triggerRef={reconcileRef}
|
||||
style={{ width: 275 }}
|
||||
isOpen={reconcileOpen}
|
||||
onOpenChange={() => setReconcileOpen(false)}
|
||||
>
|
||||
<ReconcileMenu
|
||||
account={account}
|
||||
onClose={() => setReconcileOpen(false)}
|
||||
onReconcile={onReconcile}
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={
|
||||
<View>
|
||||
<SvgLockClosed width={14} height={14} />
|
||||
</View>
|
||||
</Button>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
triggerRef={reconcileRef}
|
||||
style={{ width: 275 }}
|
||||
isOpen={reconcileOpen}
|
||||
onOpenChange={() => setReconcileOpen(false)}
|
||||
>
|
||||
<ReconcileMenu
|
||||
account={account}
|
||||
onClose={() => setReconcileOpen(false)}
|
||||
onReconcile={onReconcile}
|
||||
/>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
)}
|
||||
</View>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={
|
||||
splitsExpanded.state.mode === 'collapse'
|
||||
? t('Collapse split transactions')
|
||||
: t('Expand split transactions')
|
||||
}
|
||||
style={{ padding: 6 }}
|
||||
onPress={onToggleSplits}
|
||||
>
|
||||
<View
|
||||
title={
|
||||
splitsExpanded.state.mode === 'collapse'
|
||||
? t('Collapse split transactions')
|
||||
: t('Expand split transactions')
|
||||
}
|
||||
style={{ padding: 6 }}
|
||||
onPress={onToggleSplits}
|
||||
>
|
||||
<View
|
||||
title={
|
||||
splitsExpanded.state.mode === 'collapse'
|
||||
? t('Collapse split transactions')
|
||||
: t('Expand split transactions')
|
||||
}
|
||||
>
|
||||
{splitsExpanded.state.mode === 'collapse' ? (
|
||||
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
|
||||
) : (
|
||||
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
{account ? (
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<DialogTrigger>
|
||||
<Button variant="bare" aria-label={t('Account menu')}>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
{splitsExpanded.state.mode === 'collapse' ? (
|
||||
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
|
||||
) : (
|
||||
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
{account ? (
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<DialogTrigger>
|
||||
<Button variant="bare" aria-label={t('Account menu')}>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover style={{ minWidth: 275 }}>
|
||||
<Dialog>
|
||||
<AccountMenu
|
||||
account={account}
|
||||
canSync={canSync}
|
||||
showNetWorthChart={showNetWorthChart}
|
||||
canShowBalances={
|
||||
canCalculateBalance ? canCalculateBalance() : false
|
||||
}
|
||||
isSorted={isSorted}
|
||||
showBalances={showBalances}
|
||||
showCleared={showCleared}
|
||||
showReconciled={showReconciled}
|
||||
onMenuSelect={onMenuSelect}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<DialogTrigger>
|
||||
<Button variant="bare" aria-label={t('Account menu')}>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
<Popover style={{ minWidth: 275 }}>
|
||||
<Dialog>
|
||||
<AccountMenu
|
||||
account={account}
|
||||
canSync={canSync}
|
||||
showNetWorthChart={showNetWorthChart}
|
||||
canShowBalances={
|
||||
canCalculateBalance ? canCalculateBalance() : false
|
||||
}
|
||||
isSorted={isSorted}
|
||||
showBalances={showBalances}
|
||||
showCleared={showCleared}
|
||||
showReconciled={showReconciled}
|
||||
onMenuSelect={onMenuSelect}
|
||||
/>
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ flex: '0 0 auto' }}>
|
||||
<DialogTrigger>
|
||||
<Button variant="bare" aria-label={t('Account menu')}>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
slot="close"
|
||||
onMenuSelect={onMenuSelect}
|
||||
items={[
|
||||
...(isSorted
|
||||
? [
|
||||
{
|
||||
name: 'remove-sorting',
|
||||
text: t('Remove all sorting'),
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{ name: 'export', text: t('Export') },
|
||||
{
|
||||
name: 'toggle-net-worth-chart',
|
||||
text: showNetWorthChart
|
||||
? t('Hide balance chart')
|
||||
: t('Show balance chart'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
)}
|
||||
</SpaceBetween>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
slot="close"
|
||||
onMenuSelect={onMenuSelect}
|
||||
items={[
|
||||
...(isSorted
|
||||
? [
|
||||
{
|
||||
name: 'remove-sorting',
|
||||
text: t('Remove all sorting'),
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{ name: 'export', text: t('Export') },
|
||||
{
|
||||
name: 'toggle-net-worth-chart',
|
||||
text: showNetWorthChart
|
||||
? t('Hide balance chart')
|
||||
: t('Show balance chart'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</View>
|
||||
)}
|
||||
</Stack>
|
||||
{filterConditions?.length > 0 && (
|
||||
<FiltersStack
|
||||
|
||||
@@ -207,6 +207,7 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
fields={fields}
|
||||
mapping={mapping}
|
||||
setMapping={setMapping}
|
||||
selectMinWidth={300}
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, margin: '1em 0 .5em 0' }}>
|
||||
|
||||
@@ -38,6 +38,7 @@ type FieldMappingProps = {
|
||||
fields: MappableFieldWithExample[];
|
||||
mapping: Map<string, string>;
|
||||
setMapping: (field: string, value: string) => void;
|
||||
selectMinWidth?: number;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ export function FieldMapping({
|
||||
fields,
|
||||
mapping,
|
||||
setMapping,
|
||||
selectMinWidth = 50,
|
||||
isMobile = false,
|
||||
}: FieldMappingProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -59,6 +61,7 @@ export function FieldMapping({
|
||||
const equalsIconWidth = 12;
|
||||
|
||||
const calculatedSelectWidth = Math.max(
|
||||
selectMinWidth,
|
||||
...fields.flatMap(field =>
|
||||
field.syncFields.map(({ field }) => field.length * 8 + 30),
|
||||
),
|
||||
@@ -74,11 +77,6 @@ export function FieldMapping({
|
||||
const commonCellStyle = { height: '100%', border: 0 };
|
||||
const iconCellStyle = { ...commonCellStyle };
|
||||
|
||||
const selectStyle = {
|
||||
minWidth: isMobile ? '10ch' : '30ch',
|
||||
maxWidth: isMobile ? '15ch' : '50ch',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
@@ -120,7 +118,7 @@ export function FieldMapping({
|
||||
<Cell
|
||||
value={t('Bank field')}
|
||||
width={calculatedSelectWidth}
|
||||
style={{ paddingLeft: 0, ...selectStyle }}
|
||||
style={{ paddingLeft: 0 }}
|
||||
/>
|
||||
<Cell value="" width={equalsCellWidth} style={{ padding: 0 }} />
|
||||
<Cell
|
||||
@@ -172,11 +170,7 @@ export function FieldMapping({
|
||||
</View>
|
||||
</Cell>
|
||||
|
||||
<Cell
|
||||
width={calculatedSelectWidth}
|
||||
style={{ ...iconCellStyle, ...selectStyle }}
|
||||
plain
|
||||
>
|
||||
<Cell width={calculatedSelectWidth} style={iconCellStyle} plain>
|
||||
<Select
|
||||
aria-label={t('Synced field to map to {{field}}', {
|
||||
field: field.actualField,
|
||||
@@ -187,7 +181,7 @@ export function FieldMapping({
|
||||
])}
|
||||
value={mapping.get(field.actualField)}
|
||||
style={{
|
||||
width: '100%',
|
||||
width: calculatedSelectWidth,
|
||||
}}
|
||||
onChange={newValue => {
|
||||
if (newValue) setMapping(field.actualField, newValue);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
type SearchProps = {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
@@ -21,7 +21,7 @@ type SearchProps = {
|
||||
};
|
||||
|
||||
export function Search({
|
||||
ref,
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
@@ -70,7 +70,7 @@ export function Search({
|
||||
/>
|
||||
|
||||
<Input
|
||||
ref={ref}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onEscape={() => onChange('')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ComponentPropsWithRef,
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
@@ -11,7 +12,7 @@ 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 { css, cx } from '@emotion/css';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
type FieldLabelProps = {
|
||||
title: string;
|
||||
@@ -47,32 +48,28 @@ const valueStyle = {
|
||||
|
||||
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
|
||||
|
||||
export function InputField({
|
||||
disabled,
|
||||
style,
|
||||
onUpdate,
|
||||
ref,
|
||||
...props
|
||||
}: InputFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
autoCorrect="false"
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
style={{
|
||||
...valueStyle,
|
||||
...style,
|
||||
color: disabled ? theme.tableTextInactive : theme.tableText,
|
||||
backgroundColor: disabled
|
||||
? theme.formInputTextReadOnlySelection
|
||||
: theme.tableBackground,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
({ disabled, style, onUpdate, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
autoCorrect="false"
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
style={{
|
||||
...valueStyle,
|
||||
...style,
|
||||
color: disabled ? theme.tableTextInactive : theme.tableText,
|
||||
backgroundColor: disabled
|
||||
? theme.formInputTextReadOnlySelection
|
||||
: theme.tableBackground,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InputField.displayName = 'InputField';
|
||||
|
||||
@@ -81,63 +78,60 @@ type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
|
||||
textStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
const defaultTapFieldClassName = () =>
|
||||
css({
|
||||
...valueStyle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.tableBackground,
|
||||
'&[data-disabled]': {
|
||||
backgroundColor: theme.formInputTextReadOnlySelection,
|
||||
},
|
||||
'&[data-pressed]': {
|
||||
opacity: 0.5,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export function TapField({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
rightContent,
|
||||
textStyle,
|
||||
ref,
|
||||
...props
|
||||
}: TapFieldProps) {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
bounce={false}
|
||||
className={renderProps =>
|
||||
cx(
|
||||
defaultTapFieldClassName(),
|
||||
typeof className === 'function' ? className(renderProps) : className,
|
||||
)
|
||||
const defaultTapFieldStyle: ComponentPropsWithoutRef<
|
||||
typeof Button
|
||||
>['style'] = ({ isDisabled, isPressed, isHovered }) => ({
|
||||
...valueStyle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.tableBackground,
|
||||
...(isDisabled && {
|
||||
backgroundColor: theme.formInputTextReadOnlySelection,
|
||||
}),
|
||||
...(isPressed
|
||||
? {
|
||||
opacity: 0.5,
|
||||
boxShadow: 'none',
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
userSelect: 'none',
|
||||
textAlign: 'left',
|
||||
...textStyle,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{!props.isDisabled && rightContent}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
: {}),
|
||||
...(isHovered
|
||||
? {
|
||||
boxShadow: 'none',
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
export const TapField = forwardRef<HTMLButtonElement, TapFieldProps>(
|
||||
({ value, children, rightContent, style, textStyle, ...props }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
bounce={false}
|
||||
style={renderProps => ({
|
||||
...defaultTapFieldStyle(renderProps),
|
||||
...(typeof style === 'function' ? style(renderProps) : style),
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
userSelect: 'none',
|
||||
textAlign: 'left',
|
||||
...textStyle,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{!props.isDisabled && rightContent}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TapField.displayName = 'TapField';
|
||||
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
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,27 +632,22 @@ function SelectedTransactionsFloatingActionBar({
|
||||
},
|
||||
});
|
||||
} else if (type === 'transfer') {
|
||||
onSetTransfer?.({
|
||||
ids: selectedTransactionsArray,
|
||||
payees,
|
||||
onSuccess: ids =>
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Successfully marked {{count}} transactions as transfer.',
|
||||
{
|
||||
count: ids.length,
|
||||
},
|
||||
),
|
||||
}),
|
||||
});
|
||||
onSetTransfer?.(selectedTransactionsArray, payees, ids =>
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Successfully marked {{count}} transactions as transfer.',
|
||||
{
|
||||
count: ids.length,
|
||||
},
|
||||
),
|
||||
}),
|
||||
);
|
||||
} else if (type === 'merge') {
|
||||
onMerge?.({
|
||||
ids: selectedTransactionsArray,
|
||||
onSuccess: () =>
|
||||
showUndoNotification({
|
||||
message: t('Successfully merged transactions'),
|
||||
}),
|
||||
});
|
||||
onMerge?.(selectedTransactionsArray, () =>
|
||||
showUndoNotification({
|
||||
message: t('Successfully merged transactions'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
setIsMoreOptionsMenuOpen(false);
|
||||
}}
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
|
||||
type GetPrettyPayeeProps = {
|
||||
t: ReturnType<typeof useTranslation>['t'];
|
||||
transaction?: TransactionEntity | null;
|
||||
payee?: PayeeEntity | null;
|
||||
transferAccount?: AccountEntity | null;
|
||||
transaction?: TransactionEntity;
|
||||
payee?: PayeeEntity;
|
||||
transferAccount?: AccountEntity;
|
||||
};
|
||||
|
||||
export function getPrettyPayee({
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
TapField,
|
||||
} from '@desktop-client/components/mobile/MobileForms';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
|
||||
import {
|
||||
type Modal as ModalType,
|
||||
pushModal,
|
||||
@@ -56,7 +54,7 @@ export function CoverModal({
|
||||
const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openCategoryModal = useCallback(() => {
|
||||
const onCategoryClick = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
@@ -81,13 +79,6 @@ export function CoverModal({
|
||||
|
||||
const fromCategory = categories.find(c => c.id === fromCategoryId);
|
||||
|
||||
const isInitialMount = useInitialMount();
|
||||
useEffect(() => {
|
||||
if (isInitialMount) {
|
||||
openCategoryModal();
|
||||
}
|
||||
}, [isInitialMount, openCategoryModal]);
|
||||
|
||||
return (
|
||||
<Modal name="cover">
|
||||
{({ state: { close } }) => (
|
||||
@@ -98,13 +89,7 @@ export function CoverModal({
|
||||
/>
|
||||
<View>
|
||||
<FieldLabel title={t('Cover from a category:')} />
|
||||
<InitialFocus>
|
||||
<TapField
|
||||
autoFocus
|
||||
value={fromCategory?.name}
|
||||
onPress={openCategoryModal}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<TapField value={fromCategory?.name} onPress={onCategoryClick} />
|
||||
</View>
|
||||
|
||||
<View
|
||||
|
||||
@@ -446,25 +446,28 @@ export function KeyboardShortcutModal() {
|
||||
padding: '0 16px 16px 16px',
|
||||
}}
|
||||
>
|
||||
<InitialFocus>
|
||||
<Search
|
||||
value={searchText}
|
||||
isInModal
|
||||
onChange={text => {
|
||||
setSearchText(text);
|
||||
// Clear category selection when searching to search all shortcuts
|
||||
if (text && selectedCategoryId) {
|
||||
setSelectedCategoryId(null);
|
||||
}
|
||||
}}
|
||||
placeholder={t('Search shortcuts')}
|
||||
width="100%"
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderColor: theme.formInputBorder,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
<InitialFocus<HTMLInputElement>>
|
||||
{ref => (
|
||||
<Search
|
||||
inputRef={ref}
|
||||
value={searchText}
|
||||
isInModal
|
||||
onChange={text => {
|
||||
setSearchText(text);
|
||||
// Clear category selection when searching to search all shortcuts
|
||||
if (text && selectedCategoryId) {
|
||||
setSelectedCategoryId(null);
|
||||
}
|
||||
}}
|
||||
placeholder={t('Search shortcuts')}
|
||||
width="100%"
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderColor: theme.formInputBorder,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</InitialFocus>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
Cell,
|
||||
} from '@desktop-client/components/table';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { closeModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
@@ -107,8 +110,12 @@ export function SelectLinkedAccountsModal({
|
||||
}, [externalAccounts, syncSource, requisitionId]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const dispatch = useDispatch();
|
||||
const localAccounts = useAccounts().filter(a => a.closed === 0);
|
||||
const [draftLinkAccounts] = useState<Map<string, 'linking' | 'unlinking'>>(
|
||||
new Map(),
|
||||
);
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
return Object.fromEntries(
|
||||
@@ -216,133 +223,216 @@ export function SelectLinkedAccountsModal({
|
||||
|
||||
if (localAccountId) {
|
||||
updatedAccounts[externalAccount.account_id] = localAccountId;
|
||||
draftLinkAccounts.set(externalAccount.account_id, 'linking');
|
||||
} else {
|
||||
delete updatedAccounts[externalAccount.account_id];
|
||||
draftLinkAccounts.set(externalAccount.account_id, 'unlinking');
|
||||
}
|
||||
|
||||
return updatedAccounts;
|
||||
});
|
||||
}
|
||||
|
||||
const getChosenAccount = (accountId: string) => {
|
||||
const chosenId = chosenAccounts[accountId];
|
||||
if (!chosenId) return undefined;
|
||||
|
||||
if (chosenId === addOnBudgetAccountOption.id) {
|
||||
return addOnBudgetAccountOption;
|
||||
}
|
||||
if (chosenId === addOffBudgetAccountOption.id) {
|
||||
return addOffBudgetAccountOption;
|
||||
}
|
||||
|
||||
return localAccounts.find(acc => acc.id === chosenId);
|
||||
};
|
||||
|
||||
const label = useMemo(() => {
|
||||
const s = new Set(draftLinkAccounts.values());
|
||||
if (s.has('linking') && s.has('unlinking')) {
|
||||
return t('Link and unlink accounts');
|
||||
} else if (s.has('linking')) {
|
||||
return t('Link accounts');
|
||||
} else if (s.has('unlinking')) {
|
||||
return t('Unlink accounts');
|
||||
}
|
||||
|
||||
return t('Link or unlink accounts');
|
||||
}, [draftLinkAccounts, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="select-linked-accounts"
|
||||
containerProps={{ style: { width: 1000 } }}
|
||||
containerProps={{
|
||||
style: isNarrowWidth
|
||||
? {
|
||||
width: '100vw',
|
||||
maxWidth: '100vw',
|
||||
height: '100vh',
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
: { width: 1000 },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<View
|
||||
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
|
||||
>
|
||||
<ModalHeader
|
||||
title={t('Link Accounts')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<Text style={{ marginBottom: 10 }}>
|
||||
<Trans>
|
||||
We found the following accounts. Select which ones you want to
|
||||
add:
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
padding: isNarrowWidth ? '0 16px' : '0 20px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<Cell name={t('Institution to Sync')} width={175} />
|
||||
<Cell name={t('Bank Account To Sync')} width={175} />
|
||||
<Cell name={t('Balance')} width={80} />
|
||||
<Cell name={t('Account in Actual')} width="flex" />
|
||||
<Cell name={t('Actions')} width={150} />
|
||||
</TableHeader>
|
||||
|
||||
<Table<
|
||||
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
|
||||
id: string;
|
||||
}
|
||||
>
|
||||
items={propsWithSortedExternalAccounts.externalAccounts.map(
|
||||
account => ({
|
||||
...account,
|
||||
id: account.account_id,
|
||||
}),
|
||||
)}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
getItemKey={String}
|
||||
renderItem={({ item }) => (
|
||||
<View key={item.id}>
|
||||
<TableRow
|
||||
externalAccount={item}
|
||||
chosenAccount={
|
||||
chosenAccounts[item.account_id] ===
|
||||
addOnBudgetAccountOption.id
|
||||
? addOnBudgetAccountOption
|
||||
: chosenAccounts[item.account_id] ===
|
||||
addOffBudgetAccountOption.id
|
||||
? addOffBudgetAccountOption
|
||||
: localAccounts.find(
|
||||
acc => chosenAccounts[item.account_id] === acc.id,
|
||||
)
|
||||
}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
<Text style={{ marginBottom: 20 }}>
|
||||
<Trans>
|
||||
We found the following accounts. Select which ones you want to
|
||||
add:
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isNarrowWidth ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{propsWithSortedExternalAccounts.externalAccounts.map(account => (
|
||||
<AccountCard
|
||||
key={account.account_id}
|
||||
externalAccount={account}
|
||||
chosenAccount={getChosenAccount(account.account_id)}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<Cell value={t('Institution to Sync')} width={175} />
|
||||
<Cell value={t('Bank Account To Sync')} width={175} />
|
||||
<Cell value={t('Balance')} width={80} />
|
||||
<Cell value={t('Account in Actual')} width="flex" />
|
||||
<Cell value={t('Actions')} width={150} />
|
||||
</TableHeader>
|
||||
|
||||
<Table<
|
||||
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
|
||||
id: string;
|
||||
}
|
||||
>
|
||||
items={propsWithSortedExternalAccounts.externalAccounts.map(
|
||||
account => ({
|
||||
...account,
|
||||
id: account.account_id,
|
||||
}),
|
||||
)}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
renderItem={({ item }) => (
|
||||
<View key={item.id}>
|
||||
<TableRow
|
||||
externalAccount={item}
|
||||
chosenAccount={getChosenAccount(item.account_id)}
|
||||
unlinkedAccounts={unlinkedAccounts}
|
||||
onSetLinkedAccount={onSetLinkedAccount}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 10,
|
||||
justifyContent: isNarrowWidth ? 'center' : 'flex-end',
|
||||
...(isNarrowWidth
|
||||
? {
|
||||
padding: '16px',
|
||||
flexShrink: 0,
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
}
|
||||
: { marginTop: 10 }),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={onNext}
|
||||
isDisabled={!Object.keys(chosenAccounts).length}
|
||||
isDisabled={draftLinkAccounts.size === 0}
|
||||
style={
|
||||
isNarrowWidth
|
||||
? {
|
||||
width: '100%',
|
||||
height: '44px',
|
||||
fontSize: '1em',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Trans>Link accounts</Trans>
|
||||
{label}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
} else if (typeof externalAccount.institution?.name === 'string') {
|
||||
return externalAccount?.institution?.name ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
type ExternalAccount =
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount;
|
||||
|
||||
type TableRowProps = {
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount;
|
||||
type SharedAccountRowProps = {
|
||||
externalAccount: ExternalAccount;
|
||||
chosenAccount: { id: string; name: string } | undefined;
|
||||
unlinkedAccounts: AccountEntity[];
|
||||
onSetLinkedAccount: (
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
externalAccount: ExternalAccount,
|
||||
localAccountId: string | null | undefined,
|
||||
) => void;
|
||||
};
|
||||
|
||||
function getAvailableAccountOptions(
|
||||
unlinkedAccounts: AccountEntity[],
|
||||
chosenAccount: { id: string; name: string } | undefined,
|
||||
addOnBudgetAccountOption: { id: string; name: string },
|
||||
addOffBudgetAccountOption: { id: string; name: string },
|
||||
): AutocompleteItem[] {
|
||||
const options: AutocompleteItem[] = [...unlinkedAccounts];
|
||||
if (
|
||||
chosenAccount &&
|
||||
chosenAccount.id !== addOnBudgetAccountOption.id &&
|
||||
chosenAccount.id !== addOffBudgetAccountOption.id
|
||||
) {
|
||||
options.push(chosenAccount);
|
||||
}
|
||||
options.push(addOnBudgetAccountOption, addOffBudgetAccountOption);
|
||||
return options;
|
||||
}
|
||||
|
||||
type TableRowProps = SharedAccountRowProps;
|
||||
|
||||
function TableRow({
|
||||
externalAccount,
|
||||
chosenAccount,
|
||||
@@ -352,12 +442,12 @@ function TableRow({
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
const format = useFormat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts];
|
||||
if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) {
|
||||
availableAccountOptions.push(chosenAccount);
|
||||
}
|
||||
availableAccountOptions.push(
|
||||
const availableAccountOptions = getAvailableAccountOptions(
|
||||
unlinkedAccounts,
|
||||
chosenAccount,
|
||||
addOnBudgetAccountOption,
|
||||
addOffBudgetAccountOption,
|
||||
);
|
||||
@@ -391,7 +481,11 @@ function TableRow({
|
||||
</Tooltip>
|
||||
</Field>
|
||||
<Field width={80}>
|
||||
<PrivacyFilter>{externalAccount.balance}</PrivacyFilter>
|
||||
<PrivacyFilter>
|
||||
{!isNaN(Number(externalAccount.balance))
|
||||
? format(externalAccount.balance.toString(), 'financial')
|
||||
: t('Unknown')}
|
||||
</PrivacyFilter>
|
||||
</Field>
|
||||
<Field
|
||||
width="flex"
|
||||
@@ -441,3 +535,161 @@ function TableRow({
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
} else if (typeof externalAccount.institution?.name === 'string') {
|
||||
return externalAccount?.institution?.name ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
type AccountCardProps = SharedAccountRowProps;
|
||||
|
||||
function AccountCard({
|
||||
externalAccount,
|
||||
chosenAccount,
|
||||
unlinkedAccounts,
|
||||
onSetLinkedAccount,
|
||||
}: AccountCardProps) {
|
||||
const [focusedField, setFocusedField] = useState<string | null>(null);
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
const format = useFormat();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableAccountOptions = getAvailableAccountOptions(
|
||||
unlinkedAccounts,
|
||||
chosenAccount,
|
||||
addOnBudgetAccountOption,
|
||||
addOffBudgetAccountOption,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="column"
|
||||
spacing={2}
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
minHeight: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1em',
|
||||
color: theme.pageText,
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{externalAccount.name}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
}}
|
||||
>
|
||||
{getInstitutionName(externalAccount)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
<Trans>Balance:</Trans>{' '}
|
||||
<PrivacyFilter>
|
||||
{externalAccount.balance != null
|
||||
? format(externalAccount.balance.toString(), 'financial')
|
||||
: t('Unknown')}
|
||||
</PrivacyFilter>
|
||||
</View>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
style={{
|
||||
fontSize: '0.9em',
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Linked to:</Trans>
|
||||
</Text>
|
||||
{chosenAccount ? (
|
||||
<Text style={{ color: theme.noticeTextLight, fontWeight: 500 }}>
|
||||
{chosenAccount.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: theme.pageTextSubdued }}>
|
||||
<Trans>Not linked</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{focusedField === 'account' && (
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<Autocomplete
|
||||
focused
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={availableAccountOptions}
|
||||
onSelect={value => {
|
||||
onSetLinkedAccount(externalAccount, value);
|
||||
setFocusedField(null);
|
||||
}}
|
||||
inputProps={{
|
||||
onBlur: () => setFocusedField(null),
|
||||
placeholder: t('Select account...'),
|
||||
}}
|
||||
value={chosenAccount?.id}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{chosenAccount ? (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onSetLinkedAccount(externalAccount, null);
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
<Trans>Remove bank sync</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
setFocusedField('account');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
<Trans>Link account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
Suspense,
|
||||
lazy,
|
||||
type ChangeEvent,
|
||||
@@ -93,8 +92,12 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
error,
|
||||
} = useFormulaExecution(formula, queriesRef.current, queriesVersion);
|
||||
|
||||
const colorVariables = useMemo(
|
||||
() => ({
|
||||
// Execute color formula with access to main result via named expression
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
queriesRef.current,
|
||||
queriesVersion,
|
||||
{
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
@@ -103,14 +106,7 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
}),
|
||||
[result, themeColors],
|
||||
);
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
queriesRef.current,
|
||||
queriesVersion,
|
||||
colorVariables,
|
||||
},
|
||||
);
|
||||
|
||||
const handleQueriesChange = useCallback(
|
||||
@@ -391,7 +387,16 @@ function FormulaInner({ widget }: FormulaInnerProps) {
|
||||
<Suspense fallback={<div style={{ height: 32 }} />}>
|
||||
<FormulaEditor
|
||||
value={colorFormula}
|
||||
variables={colorVariables}
|
||||
variables={{
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
acc[`theme_${key}`] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
}}
|
||||
onChange={setColorFormula}
|
||||
mode="query"
|
||||
queries={queriesRef.current}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { View } from '@actual-app/components/view';
|
||||
@@ -42,8 +42,12 @@ export function FormulaCard({
|
||||
meta?.queriesVersion,
|
||||
);
|
||||
|
||||
const colorVariables = useMemo(
|
||||
() => ({
|
||||
// Execute color formula with access to main result via named expression
|
||||
const { result: colorResult, error: colorError } = useFormulaExecution(
|
||||
colorFormula,
|
||||
meta?.queries || {},
|
||||
meta?.queriesVersion,
|
||||
{
|
||||
RESULT: result ?? 0,
|
||||
...Object.entries(themeColors).reduce(
|
||||
(acc, [key, value]) => {
|
||||
@@ -52,14 +56,7 @@ 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
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ScheduleLink({
|
||||
statuses,
|
||||
} = useSchedules({ query: schedulesQuery });
|
||||
|
||||
const searchInput = useRef<HTMLInputElement | null>(null);
|
||||
const searchInput = useRef(null);
|
||||
|
||||
async function onSelect(scheduleId: string) {
|
||||
if (ids?.length > 0) {
|
||||
@@ -105,20 +105,15 @@ export function ScheduleLink({
|
||||
{ count: ids?.length ?? 0 },
|
||||
)}
|
||||
</Text>
|
||||
<InitialFocus<HTMLInputElement>>
|
||||
{node => (
|
||||
<Search
|
||||
ref={r => {
|
||||
node.current = r;
|
||||
searchInput.current = r;
|
||||
}}
|
||||
isInModal
|
||||
width={300}
|
||||
placeholder={t('Filter schedules…')}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
)}
|
||||
<InitialFocus>
|
||||
<Search
|
||||
inputRef={searchInput}
|
||||
isInModal
|
||||
width={300}
|
||||
placeholder={t('Filter schedules…')}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
</InitialFocus>
|
||||
{ids.length === 1 && (
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
forwardRef,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
@@ -10,6 +9,7 @@ import React, {
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
@@ -45,7 +45,6 @@ import DateSelectRight from './DateSelect.right.png';
|
||||
|
||||
import { InputField } from '@desktop-client/components/mobile/MobileForms';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
const pickerStyles: CSSProperties = {
|
||||
@@ -235,7 +234,7 @@ type DateSelectProps = {
|
||||
embedded?: boolean;
|
||||
dateFormat: string;
|
||||
openOnFocus?: boolean;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
|
||||
clearOnBlur?: boolean;
|
||||
onUpdate?: (selectedDate: string) => void;
|
||||
@@ -251,7 +250,7 @@ function DateSelectDesktop({
|
||||
embedded,
|
||||
dateFormat = 'yyyy-MM-dd',
|
||||
openOnFocus = true,
|
||||
ref,
|
||||
inputRef: originalInputRef,
|
||||
shouldSaveFromKey = defaultShouldSaveFromKey,
|
||||
clearOnBlur = true,
|
||||
onUpdate,
|
||||
@@ -270,8 +269,13 @@ function DateSelectDesktop({
|
||||
const picker = useRef(null);
|
||||
const [value, setValue] = useState(parsedDefaultValue);
|
||||
const [open, setOpen] = useState(embedded || isOpen || false);
|
||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(innerRef, ref);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (originalInputRef) {
|
||||
originalInputRef.current = inputRef.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// This is confusing, so let me explain: `selectedValue` should be
|
||||
// renamed to `currentValue`. It represents the current highlighted
|
||||
@@ -362,8 +366,8 @@ function DateSelectDesktop({
|
||||
onKeyDown?.(e);
|
||||
} else if (!open) {
|
||||
setOpen(true);
|
||||
if (innerRef.current) {
|
||||
innerRef.current.setSelectionRange(0, 10000);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setSelectionRange(0, 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +383,7 @@ function DateSelectDesktop({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerRef={innerRef}
|
||||
triggerRef={inputRef}
|
||||
placement="bottom start"
|
||||
offset={2}
|
||||
isOpen={open}
|
||||
@@ -398,7 +402,7 @@ function DateSelectDesktop({
|
||||
<Input
|
||||
id={id}
|
||||
{...inputProps}
|
||||
ref={mergedRef}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onPointerUp={() => {
|
||||
if (!embedded) {
|
||||
|
||||
@@ -83,7 +83,7 @@ function GlobalFeatureToggle({
|
||||
error,
|
||||
children,
|
||||
}: GlobalFeatureToggleProps) {
|
||||
const [enabled, setEnabled] = useSyncedPref(prefName);
|
||||
const [enabled, setEnabled] = useSyncedPref(prefName, { isGlobal: true });
|
||||
|
||||
return (
|
||||
<label style={{ display: 'flex' }}>
|
||||
|
||||
@@ -848,7 +848,7 @@ export function SelectedItemsButton<Name extends string>({
|
||||
typeof name === 'function' ? name(selectedItems.size) : name;
|
||||
|
||||
return (
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<View style={{ marginLeft: 10, flexShrink: 0 }}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
|
||||
|
||||
type AmountInputProps = {
|
||||
id?: string;
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value: number;
|
||||
zeroSign?: '-' | '+';
|
||||
sign?: '-' | '+';
|
||||
@@ -42,7 +42,7 @@ type AmountInputProps = {
|
||||
|
||||
export function AmountInput({
|
||||
id,
|
||||
ref,
|
||||
inputRef,
|
||||
value: initialValue,
|
||||
zeroSign = '-', // + or -
|
||||
sign,
|
||||
@@ -83,13 +83,13 @@ export function AmountInput({
|
||||
[initialValue, isFocused, getDisplayValue],
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(ref, innerRef);
|
||||
const buttonRef = useRef(null);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
innerRef.current?.focus();
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
@@ -105,11 +105,9 @@ export function AmountInput({
|
||||
}, [symbol, value, format]);
|
||||
|
||||
useEffect(() => {
|
||||
if (innerRef.current) {
|
||||
if (ref.current) {
|
||||
(
|
||||
innerRef.current as HTMLInputElement & {
|
||||
getCurrentAmount?: () => number;
|
||||
}
|
||||
ref.current as HTMLInputElement & { getCurrentAmount?: () => number }
|
||||
).getCurrentAmount = () => getAmount();
|
||||
}
|
||||
}, [getAmount]);
|
||||
@@ -158,7 +156,7 @@ export function AmountInput({
|
||||
}
|
||||
|
||||
function onInputAmountBlur(e) {
|
||||
if (!innerRef.current?.contains(e.relatedTarget)) {
|
||||
if (!ref.current?.contains(e.relatedTarget)) {
|
||||
const amount = getAmount();
|
||||
fireUpdate(amount);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function GenericInput({
|
||||
case 'currency':
|
||||
return (
|
||||
<AmountInput
|
||||
ref={ref}
|
||||
inputRef={ref}
|
||||
value={value}
|
||||
onUpdate={v => onChange(v)}
|
||||
sign={options?.inflow || options?.outflow ? '+' : undefined}
|
||||
@@ -269,7 +269,7 @@ export function GenericInput({
|
||||
value={value}
|
||||
dateFormat={dateFormat}
|
||||
openOnFocus={false}
|
||||
ref={ref}
|
||||
inputRef={ref}
|
||||
inputProps={{ placeholder: dateFormat.toLowerCase() }}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
|
||||
@@ -14,8 +14,8 @@ export function useMergedRefs<T>(
|
||||
[...refs].forEach(ref => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref != null) {
|
||||
ref.current = value;
|
||||
} else if (ref != null && 'current' in ref) {
|
||||
(ref as RefObject<T>).current = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ 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>>(
|
||||
@@ -18,10 +19,11 @@ export function useSyncedPref<K extends keyof SyncedPrefs>(
|
||||
dispatch(
|
||||
saveSyncedPrefs({
|
||||
prefs: { [prefName]: value },
|
||||
isGlobal: options?.isGlobal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[prefName, dispatch],
|
||||
[prefName, dispatch, options?.isGlobal],
|
||||
);
|
||||
const pref = useSelector(state => state.prefs.synced[prefName]);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
deleteTransaction,
|
||||
isTemporaryId,
|
||||
realizeTempTransactions,
|
||||
ungroupTransaction,
|
||||
ungroupTransactions,
|
||||
@@ -59,22 +58,6 @@ 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();
|
||||
@@ -473,11 +456,11 @@ export function useTransactionBatchActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const onSetTransfer = async ({
|
||||
ids,
|
||||
payees,
|
||||
onSuccess,
|
||||
}: SetTransferProps) => {
|
||||
const onSetTransfer = async (
|
||||
ids: string[],
|
||||
payees: PayeeEntity[],
|
||||
onSuccess: (ids: string[]) => void,
|
||||
) => {
|
||||
const onConfirmTransfer = async (ids: string[]) => {
|
||||
const { data: transactions } = await aqlQuery(
|
||||
q('transactions')
|
||||
@@ -523,60 +506,12 @@ export function useTransactionBatchActions() {
|
||||
);
|
||||
};
|
||||
|
||||
const onMerge = async ({ ids, onSuccess }: MergeProps) => {
|
||||
const onMerge = async (ids: string[], onSuccess: () => void) => {
|
||||
await send(
|
||||
'transactions-merge',
|
||||
ids.map(id => ({ id })),
|
||||
);
|
||||
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));
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -587,6 +522,5 @@ export function useTransactionBatchActions() {
|
||||
onBatchUnlinkSchedule,
|
||||
onSetTransfer,
|
||||
onMerge,
|
||||
onBatchSave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ export type Modal =
|
||||
| {
|
||||
name: 'payee-autocomplete';
|
||||
options: {
|
||||
onSelect: (payeeId: string, payeeName: string) => void;
|
||||
onSelect: (payeeId: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,16 +108,18 @@ export const saveGlobalPrefs = createAppAsyncThunk(
|
||||
|
||||
type SaveSyncedPrefsPayload = {
|
||||
prefs: SyncedPrefs;
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
export const saveSyncedPrefs = createAppAsyncThunk(
|
||||
`${sliceName}/saveSyncedPrefs`,
|
||||
async ({ prefs }: SaveSyncedPrefsPayload, { dispatch }) => {
|
||||
async ({ prefs, isGlobal }: SaveSyncedPrefsPayload, { dispatch }) => {
|
||||
await Promise.all(
|
||||
Object.entries(prefs).map(([prefName, value]) =>
|
||||
send('preferences/save', {
|
||||
id: prefName as keyof SyncedPrefs,
|
||||
value,
|
||||
isGlobal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -159,23 +159,22 @@ export default defineConfig(async ({ mode }) => {
|
||||
? undefined
|
||||
: VitePWA({
|
||||
registerType: 'prompt',
|
||||
// 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`,
|
||||
// },
|
||||
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',
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 604 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 743 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 743 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 779 B After Width: | Height: | Size: 743 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.1 KiB |