Compare commits
3 Commits
Transactio
...
deps/25.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380f83f3ee | ||
|
|
364110ae65 | ||
|
|
aa1f59e532 |
@@ -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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from './fixtures';
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
|
||||
import { MobileNavigation } from './page-models/mobile-navigation';
|
||||
|
||||
test.describe('Mobile Bank Sync', () => {
|
||||
let page: Page;
|
||||
let navigation: MobileNavigation;
|
||||
let bankSyncPage: MobileBankSyncPage;
|
||||
let configurationPage: ConfigurationPage;
|
||||
|
||||
test.beforeEach(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
navigation = new MobileNavigation(page);
|
||||
configurationPage = new ConfigurationPage(page);
|
||||
|
||||
await page.setViewportSize({
|
||||
width: 350,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await configurationPage.createTestFile();
|
||||
|
||||
bankSyncPage = await navigation.goToBankSyncPage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('checks the page visuals', async () => {
|
||||
await bankSyncPage.waitToLoad();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Bank Sync' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(bankSyncPage.searchBox).toBeVisible();
|
||||
await expect(bankSyncPage.searchBox).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Filter accounts…',
|
||||
);
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('searches for accounts', async () => {
|
||||
await bankSyncPage.searchFor('Checking');
|
||||
await expect(bankSyncPage.searchBox).toHaveValue('Checking');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('page handles empty state gracefully', async () => {
|
||||
await bankSyncPage.searchFor('NonExistentAccount123456789');
|
||||
|
||||
const emptyMessage = page.getByText(/No accounts found/);
|
||||
await expect(emptyMessage).toBeVisible();
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,35 +0,0 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from './fixtures';
|
||||
import { type BankSyncPage } from './page-models/bank-sync-page';
|
||||
import { ConfigurationPage } from './page-models/configuration-page';
|
||||
import { Navigation } from './page-models/navigation';
|
||||
|
||||
test.describe('Bank Sync', () => {
|
||||
let page: Page;
|
||||
let navigation: Navigation;
|
||||
let bankSyncPage: BankSyncPage;
|
||||
let configurationPage: ConfigurationPage;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
navigation = new Navigation(page);
|
||||
configurationPage = new ConfigurationPage(page);
|
||||
|
||||
await page.goto('/');
|
||||
await configurationPage.createTestFile();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
bankSyncPage = await navigation.goToBankSyncPage();
|
||||
});
|
||||
|
||||
test('checks the page visuals', async () => {
|
||||
await bankSyncPage.waitToLoad();
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,13 +0,0 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
export class BankSyncPage {
|
||||
readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async waitToLoad() {
|
||||
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
export class MobileBankSyncPage {
|
||||
readonly page: Page;
|
||||
readonly searchBox: Locator;
|
||||
readonly accountsList: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.searchBox = page.getByPlaceholder(/Filter accounts/i);
|
||||
this.accountsList = page.getByRole('main');
|
||||
}
|
||||
|
||||
async waitFor(options?: {
|
||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||
timeout?: number;
|
||||
}) {
|
||||
await this.accountsList.waitFor(options);
|
||||
}
|
||||
|
||||
async waitToLoad() {
|
||||
await this.page.waitForSelector('text=Bank Sync', { timeout: 10000 });
|
||||
}
|
||||
|
||||
async searchFor(term: string) {
|
||||
await this.searchBox.fill(term);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { MobileAccountPage } from './mobile-account-page';
|
||||
import { MobileAccountsPage } from './mobile-accounts-page';
|
||||
import { MobileBankSyncPage } from './mobile-bank-sync-page';
|
||||
import { MobileBudgetPage } from './mobile-budget-page';
|
||||
import { MobilePayeesPage } from './mobile-payees-page';
|
||||
import { MobileReportsPage } from './mobile-reports-page';
|
||||
@@ -16,7 +15,6 @@ const NAV_LINKS_HIDDEN_BY_DEFAULT = [
|
||||
'Schedules',
|
||||
'Payees',
|
||||
'Rules',
|
||||
'Bank Sync',
|
||||
'Settings',
|
||||
];
|
||||
const ROUTES_BY_PAGE = {
|
||||
@@ -26,7 +24,6 @@ const ROUTES_BY_PAGE = {
|
||||
Reports: '/reports',
|
||||
Payees: '/payees',
|
||||
Rules: '/rules',
|
||||
'Bank Sync': '/bank-sync',
|
||||
Settings: '/settings',
|
||||
};
|
||||
|
||||
@@ -185,13 +182,6 @@ export class MobileNavigation {
|
||||
);
|
||||
}
|
||||
|
||||
async goToBankSyncPage() {
|
||||
return await this.navigateToPage(
|
||||
'Bank Sync',
|
||||
() => new MobileBankSyncPage(this.page),
|
||||
);
|
||||
}
|
||||
|
||||
async goToSettingsPage() {
|
||||
return await this.navigateToPage(
|
||||
'Settings',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
import { AccountPage } from './account-page';
|
||||
import { BankSyncPage } from './bank-sync-page';
|
||||
import { PayeesPage } from './payees-page';
|
||||
import { ReportsPage } from './reports-page';
|
||||
import { RulesPage } from './rules-page';
|
||||
@@ -67,19 +66,6 @@ export class Navigation {
|
||||
return new PayeesPage(this.page);
|
||||
}
|
||||
|
||||
async goToBankSyncPage() {
|
||||
const bankSyncLink = this.page.getByRole('link', { name: 'Bank Sync' });
|
||||
|
||||
// Expand the "more" menu only if it is not already expanded
|
||||
if (!(await bankSyncLink.isVisible())) {
|
||||
await this.page.getByRole('button', { name: 'More' }).click();
|
||||
}
|
||||
|
||||
await bankSyncLink.click();
|
||||
|
||||
return new BankSyncPage(this.page);
|
||||
}
|
||||
|
||||
async goToSettingsPage() {
|
||||
const settingsLink = this.page.getByRole('link', { name: 'Settings' });
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
|
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';
|
||||
|
||||
@@ -10,13 +10,12 @@ import { View } from '@actual-app/components/view';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
|
||||
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
|
||||
import { BankSync } from './banksync';
|
||||
import { BankSyncStatus } from './BankSyncStatus';
|
||||
import { CommandBar } from './CommandBar';
|
||||
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';
|
||||
@@ -279,18 +278,7 @@ export function FinancesApp() {
|
||||
path="/rules/:id"
|
||||
element={<NarrowAlternate name="RuleEdit" />}
|
||||
/>
|
||||
<Route
|
||||
path="/bank-sync"
|
||||
element={<NarrowAlternate name="BankSync" />}
|
||||
/>
|
||||
<Route
|
||||
path="/bank-sync/account/:accountId/edit"
|
||||
element={
|
||||
<WideNotSupported redirectTo="/bank-sync">
|
||||
<MobileBankSyncAccountEditPage />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
<Route path="/bank-sync" element={<BankSync />} />
|
||||
<Route path="/tags" element={<ManageTagsPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
@@ -317,8 +305,7 @@ export function FinancesApp() {
|
||||
path="/transactions/:transactionId"
|
||||
element={
|
||||
<WideNotSupported>
|
||||
{/* <TransactionEdit /> */}
|
||||
<TransactionFormPage />
|
||||
<TransactionEdit />
|
||||
</WideNotSupported>
|
||||
}
|
||||
/>
|
||||
@@ -360,7 +347,6 @@ export function FinancesApp() {
|
||||
<Route path="/accounts" element={<MobileNavTabs />} />
|
||||
<Route path="/settings" element={<MobileNavTabs />} />
|
||||
<Route path="/reports" element={<MobileNavTabs />} />
|
||||
<Route path="/bank-sync" element={<MobileNavTabs />} />
|
||||
<Route path="/rules" element={<MobileNavTabs />} />
|
||||
<Route path="/payees" element={<MobileNavTabs />} />
|
||||
<Route path="*" element={null} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,7 +89,6 @@ export const AccountRow = memo(
|
||||
textDecorationColor: theme.pageTextSubdued,
|
||||
textUnderlineOffset: '4px',
|
||||
}}
|
||||
data-vrt-mask
|
||||
>
|
||||
{lastSyncString}
|
||||
</Cell>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import React, { useState, type ReactNode } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgQuestion } from '@actual-app/components/icons/v1';
|
||||
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';
|
||||
|
||||
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
|
||||
import { ToggleField } from '@desktop-client/components/mobile/MobileForms';
|
||||
|
||||
type CheckboxOptionProps = {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
helpMode?: 'desktop' | 'mobile';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function CheckboxOption({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
helpMode = 'desktop',
|
||||
children,
|
||||
}: CheckboxOptionProps) {
|
||||
if (helpMode === 'mobile') {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ marginBottom: 5 }}
|
||||
>
|
||||
<Text>{children}</Text>
|
||||
<ToggleField
|
||||
id={id}
|
||||
isOn={checked}
|
||||
onToggle={onChange}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LabeledCheckbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</LabeledCheckbox>
|
||||
);
|
||||
}
|
||||
|
||||
type CheckboxOptionWithHelpProps = {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
helpText: string;
|
||||
helpMode: 'desktop' | 'mobile';
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function CheckboxOptionWithHelp({
|
||||
id,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
helpText,
|
||||
helpMode,
|
||||
children,
|
||||
}: CheckboxOptionWithHelpProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
if (helpMode === 'desktop') {
|
||||
return (
|
||||
<LabeledCheckbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Tooltip content={helpText}>
|
||||
<Stack direction="row" align="center" spacing={1}>
|
||||
<Text>{children}</Text>
|
||||
<SvgQuestion height={12} width={12} cursor="pointer" />
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
</LabeledCheckbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={1} style={{ marginBottom: 5 }}>
|
||||
<Stack direction="row" align="center" justify="space-between">
|
||||
<Stack direction="row" align="center" spacing={1}>
|
||||
<Text>{children}</Text>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Help')}
|
||||
onPress={() => setShowHelp(!showHelp)}
|
||||
style={{
|
||||
padding: 0,
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
}}
|
||||
>
|
||||
<SvgQuestion height={12} width={12} />
|
||||
</Button>
|
||||
</Stack>
|
||||
<ToggleField
|
||||
id={id}
|
||||
isOn={checked}
|
||||
onToggle={onChange}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
{showHelp && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
{helpText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
type BankSyncCheckboxOptionsProps = {
|
||||
importPending: boolean;
|
||||
setImportPending: (value: boolean) => void;
|
||||
importNotes: boolean;
|
||||
setImportNotes: (value: boolean) => void;
|
||||
reimportDeleted: boolean;
|
||||
setReimportDeleted: (value: boolean) => void;
|
||||
importTransactions: boolean;
|
||||
setImportTransactions: (value: boolean) => void;
|
||||
helpMode?: 'desktop' | 'mobile';
|
||||
};
|
||||
|
||||
export function BankSyncCheckboxOptions({
|
||||
importPending,
|
||||
setImportPending,
|
||||
importNotes,
|
||||
setImportNotes,
|
||||
reimportDeleted,
|
||||
setReimportDeleted,
|
||||
importTransactions,
|
||||
setImportTransactions,
|
||||
helpMode = 'desktop',
|
||||
}: BankSyncCheckboxOptionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<CheckboxOption
|
||||
id="form_pending"
|
||||
checked={importPending}
|
||||
onChange={() => setImportPending(!importPending)}
|
||||
disabled={!importTransactions}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Import pending transactions</Trans>
|
||||
</CheckboxOption>
|
||||
|
||||
<CheckboxOption
|
||||
id="form_notes"
|
||||
checked={importNotes}
|
||||
onChange={() => setImportNotes(!importNotes)}
|
||||
disabled={!importTransactions}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Import transaction notes</Trans>
|
||||
</CheckboxOption>
|
||||
|
||||
<CheckboxOptionWithHelp
|
||||
id="form_reimport_deleted"
|
||||
checked={reimportDeleted}
|
||||
onChange={() => setReimportDeleted(!reimportDeleted)}
|
||||
disabled={!importTransactions}
|
||||
helpText={t(
|
||||
'By default imported transactions that you delete will be re-imported with the next bank sync operation. To disable this behaviour - untick this box.',
|
||||
)}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Reimport deleted transactions</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
|
||||
<CheckboxOptionWithHelp
|
||||
id="form_import_transactions"
|
||||
checked={!importTransactions}
|
||||
onChange={() => setImportTransactions(!importTransactions)}
|
||||
helpText={t(
|
||||
'Selecting this option will disable importing transactions and only import the account balance for use in reconciliation',
|
||||
)}
|
||||
helpMode={helpMode}
|
||||
>
|
||||
<Trans>Investment Account</Trans>
|
||||
</CheckboxOptionWithHelp>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,27 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgQuestion } from '@actual-app/components/icons/v1';
|
||||
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';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
import {
|
||||
defaultMappings,
|
||||
type Mappings,
|
||||
mappingsFromString,
|
||||
mappingsToString,
|
||||
} from 'loot-core/server/util/custom-sync-mapping';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import {
|
||||
type TransactionEntity,
|
||||
type AccountEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { BankSyncCheckboxOptions } from './BankSyncCheckboxOptions';
|
||||
import { FieldMapping } from './FieldMapping';
|
||||
import { useBankSyncAccountSettings } from './useBankSyncAccountSettings';
|
||||
|
||||
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import {
|
||||
@@ -19,6 +29,9 @@ import {
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { CheckboxOption } from '@desktop-client/components/modals/ImportTransactionsModal/CheckboxOption';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useTransactions } from '@desktop-client/hooks/useTransactions';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
@@ -26,7 +39,7 @@ export type TransactionDirection = 'payment' | 'deposit';
|
||||
|
||||
type MappableActualFields = 'date' | 'payee' | 'notes';
|
||||
|
||||
type MappableField = {
|
||||
export type MappableField = {
|
||||
actualField: MappableActualFields;
|
||||
syncFields: string[];
|
||||
};
|
||||
@@ -94,39 +107,15 @@ const mappableFields: MappableField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keys = path.split('.');
|
||||
let current: unknown = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export const getFields = (
|
||||
transaction: Record<string, unknown>,
|
||||
): MappableFieldWithExample[] =>
|
||||
const getFields = (transaction: TransactionEntity) =>
|
||||
mappableFields.map(field => ({
|
||||
actualField: field.actualField,
|
||||
syncFields: field.syncFields
|
||||
.map(syncField => {
|
||||
const value = getByPath(transaction, syncField);
|
||||
return value !== undefined
|
||||
? { field: syncField, example: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter(
|
||||
(item): item is { field: string; example: string } => item !== null,
|
||||
),
|
||||
.filter(syncField => transaction[syncField as keyof TransactionEntity])
|
||||
.map(syncField => ({
|
||||
field: syncField,
|
||||
example: transaction[syncField as keyof TransactionEntity],
|
||||
})),
|
||||
}));
|
||||
|
||||
export type EditSyncAccountProps = {
|
||||
@@ -135,30 +124,79 @@ export type EditSyncAccountProps = {
|
||||
|
||||
export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
transactionDirection,
|
||||
setTransactionDirection,
|
||||
importPending,
|
||||
setImportPending,
|
||||
importNotes,
|
||||
setImportNotes,
|
||||
reimportDeleted,
|
||||
setReimportDeleted,
|
||||
importTransactions,
|
||||
setImportTransactions,
|
||||
mappings,
|
||||
setMapping,
|
||||
fields,
|
||||
saveSettings,
|
||||
} = useBankSyncAccountSettings(account.id);
|
||||
const [savedMappings = mappingsToString(defaultMappings), setSavedMappings] =
|
||||
useSyncedPref(`custom-sync-mappings-${account.id}`);
|
||||
const [savedImportNotes = true, setSavedImportNotes] = useSyncedPref(
|
||||
`sync-import-notes-${account.id}`,
|
||||
);
|
||||
const [savedImportPending = true, setSavedImportPending] = useSyncedPref(
|
||||
`sync-import-pending-${account.id}`,
|
||||
);
|
||||
const [savedReimportDeleted = true, setSavedReimportDeleted] = useSyncedPref(
|
||||
`sync-reimport-deleted-${account.id}`,
|
||||
);
|
||||
const [savedImportTransactions = true, setSavedImportTransactions] =
|
||||
useSyncedPref(`sync-import-transactions-${account.id}`);
|
||||
|
||||
const [transactionDirection, setTransactionDirection] =
|
||||
useState<TransactionDirection>('payment');
|
||||
const [importPending, setImportPending] = useState(
|
||||
String(savedImportPending) === 'true',
|
||||
);
|
||||
const [importNotes, setImportNotes] = useState(
|
||||
String(savedImportNotes) === 'true',
|
||||
);
|
||||
const [reimportDeleted, setReimportDeleted] = useState(
|
||||
String(savedReimportDeleted) === 'true',
|
||||
);
|
||||
const [mappings, setMappings] = useState<Mappings>(
|
||||
mappingsFromString(savedMappings),
|
||||
);
|
||||
const [importTransactions, setImportTransactions] = useState(
|
||||
String(savedImportTransactions) === 'true',
|
||||
);
|
||||
|
||||
const transactionQuery = useMemo(
|
||||
() =>
|
||||
q('transactions')
|
||||
.filter({
|
||||
account: account.id,
|
||||
amount: transactionDirection === 'payment' ? { $lte: 0 } : { $gt: 0 },
|
||||
raw_synced_data: { $ne: null },
|
||||
})
|
||||
.options({ splits: 'none' })
|
||||
.select('*'),
|
||||
[account.id, transactionDirection],
|
||||
);
|
||||
|
||||
const { transactions } = useTransactions({
|
||||
query: transactionQuery,
|
||||
});
|
||||
|
||||
const exampleTransaction = useMemo(() => {
|
||||
const data = transactions?.[0]?.raw_synced_data;
|
||||
if (!data) return undefined;
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse transaction data:', error);
|
||||
return undefined;
|
||||
}
|
||||
}, [transactions]);
|
||||
|
||||
const onSave = async (close: () => void) => {
|
||||
saveSettings();
|
||||
const mappingsStr = mappingsToString(mappings);
|
||||
setSavedMappings(mappingsStr);
|
||||
setSavedImportPending(String(importPending));
|
||||
setSavedImportNotes(String(importNotes));
|
||||
setSavedReimportDeleted(String(reimportDeleted));
|
||||
setSavedImportTransactions(String(importTransactions));
|
||||
close();
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onUnlink = async (close: () => void) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -177,11 +215,19 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const setMapping = (field: string, value: string) => {
|
||||
setMappings(prev => {
|
||||
const updated = new Map(prev);
|
||||
updated?.get(transactionDirection)?.set(field, value);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const potentiallyTruncatedAccountName =
|
||||
account.name.length > 30 ? account.name.slice(0, 30) + '...' : account.name;
|
||||
|
||||
const mapping =
|
||||
mappings.get(transactionDirection) ?? new Map<string, string>();
|
||||
const fields = exampleTransaction ? getFields(exampleTransaction) : [];
|
||||
const mapping = mappings.get(transactionDirection);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -204,8 +250,8 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
<FieldMapping
|
||||
transactionDirection={transactionDirection}
|
||||
setTransactionDirection={setTransactionDirection}
|
||||
fields={fields}
|
||||
mapping={mapping}
|
||||
fields={fields as MappableFieldWithExample[]}
|
||||
mapping={mapping!}
|
||||
setMapping={setMapping}
|
||||
/>
|
||||
|
||||
@@ -213,17 +259,74 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
<Trans>Options</Trans>
|
||||
</Text>
|
||||
|
||||
<BankSyncCheckboxOptions
|
||||
importPending={importPending}
|
||||
setImportPending={setImportPending}
|
||||
importNotes={importNotes}
|
||||
setImportNotes={setImportNotes}
|
||||
reimportDeleted={reimportDeleted}
|
||||
setReimportDeleted={setReimportDeleted}
|
||||
importTransactions={importTransactions}
|
||||
setImportTransactions={setImportTransactions}
|
||||
helpMode="desktop"
|
||||
/>
|
||||
<CheckboxOption
|
||||
id="form_pending"
|
||||
checked={importPending && importTransactions}
|
||||
onChange={() => setImportPending(!importPending)}
|
||||
disabled={!importTransactions}
|
||||
>
|
||||
<Trans>Import pending transactions</Trans>
|
||||
</CheckboxOption>
|
||||
|
||||
<CheckboxOption
|
||||
id="form_notes"
|
||||
checked={importNotes && importTransactions}
|
||||
onChange={() => setImportNotes(!importNotes)}
|
||||
disabled={!importTransactions}
|
||||
>
|
||||
<Trans>Import transaction notes</Trans>
|
||||
</CheckboxOption>
|
||||
|
||||
<CheckboxOption
|
||||
id="form_reimport_deleted"
|
||||
checked={reimportDeleted && importTransactions}
|
||||
onChange={() => setReimportDeleted(!reimportDeleted)}
|
||||
disabled={!importTransactions}
|
||||
>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'By default imported transactions that you delete will be re-imported with the next bank sync operation. To disable this behaviour - untick this box.',
|
||||
)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Reimport deleted transactions</Trans>
|
||||
<SvgQuestion height={12} width={12} cursor="pointer" />
|
||||
</View>
|
||||
</Tooltip>
|
||||
</CheckboxOption>
|
||||
|
||||
<CheckboxOption
|
||||
id="form_import_transactions"
|
||||
checked={!importTransactions}
|
||||
onChange={() => setImportTransactions(!importTransactions)}
|
||||
>
|
||||
<Tooltip
|
||||
content={t(
|
||||
`Selecting this option will disable importing transactions and only import the account balance for use in reconciliation`,
|
||||
)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Trans>Investment Account</Trans>
|
||||
<SvgQuestion height={12} width={12} cursor="pointer" />
|
||||
</View>
|
||||
</Tooltip>
|
||||
</CheckboxOption>
|
||||
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SvgEquals } from '@actual-app/components/icons/v1';
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import {
|
||||
type MappableFieldWithExample,
|
||||
@@ -38,7 +37,6 @@ type FieldMappingProps = {
|
||||
fields: MappableFieldWithExample[];
|
||||
mapping: Map<string, string>;
|
||||
setMapping: (field: string, value: string) => void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export function FieldMapping({
|
||||
@@ -47,38 +45,11 @@ export function FieldMapping({
|
||||
fields,
|
||||
mapping,
|
||||
setMapping,
|
||||
isMobile = false,
|
||||
}: FieldMappingProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { transactionDirectionOptions } = useTransactionDirectionOptions();
|
||||
|
||||
const iconSpacing = isMobile ? 8 : 20;
|
||||
const iconPadding = isMobile ? 8 : 6;
|
||||
const arrowIconWidth = 15;
|
||||
const equalsIconWidth = 12;
|
||||
|
||||
const calculatedSelectWidth = Math.max(
|
||||
...fields.flatMap(field =>
|
||||
field.syncFields.map(({ field }) => field.length * 8 + 30),
|
||||
),
|
||||
);
|
||||
|
||||
const calculatedActualFieldWidth = isMobile
|
||||
? Math.max(50, ...fields.map(field => field.actualField.length * 8 + 20))
|
||||
: Math.max(75, ...fields.map(field => field.actualField.length * 8 + 20));
|
||||
|
||||
const arrowCellWidth = arrowIconWidth + iconSpacing + iconPadding;
|
||||
const equalsCellWidth = equalsIconWidth + iconSpacing + iconPadding;
|
||||
|
||||
const commonCellStyle = { height: '100%', border: 0 };
|
||||
const iconCellStyle = { ...commonCellStyle };
|
||||
|
||||
const selectStyle = {
|
||||
minWidth: isMobile ? '10ch' : '30ch',
|
||||
maxWidth: isMobile ? '15ch' : '50ch',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
@@ -91,7 +62,6 @@ export function FieldMapping({
|
||||
style={{
|
||||
width: '25%',
|
||||
margin: '1em 0',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -106,27 +76,19 @@ export function FieldMapping({
|
||||
<>
|
||||
<TableHeader>
|
||||
<Cell
|
||||
width={calculatedActualFieldWidth}
|
||||
value={t('Actual field')}
|
||||
width={100}
|
||||
style={{ paddingLeft: '10px' }}
|
||||
plain
|
||||
>
|
||||
<Text
|
||||
style={{ whiteSpace: 'nowrap', fontSize: 13, fontWeight: 500 }}
|
||||
>
|
||||
{calculatedActualFieldWidth > 70 ? t('Actual field') : 'Actual'}
|
||||
</Text>
|
||||
</Cell>
|
||||
<Cell value="" width={arrowCellWidth} style={{ padding: 0 }} />
|
||||
/>
|
||||
<Cell
|
||||
value={t('Bank field')}
|
||||
width={calculatedSelectWidth}
|
||||
style={{ paddingLeft: 0, ...selectStyle }}
|
||||
width={330}
|
||||
style={{ paddingLeft: '10px' }}
|
||||
/>
|
||||
<Cell value="" width={equalsCellWidth} style={{ padding: 0 }} />
|
||||
<Cell
|
||||
value={t('Example')}
|
||||
width="flex"
|
||||
style={{ paddingLeft: 0 }}
|
||||
style={{ paddingLeft: '10px' }}
|
||||
/>
|
||||
</TableHeader>
|
||||
|
||||
@@ -145,73 +107,46 @@ export function FieldMapping({
|
||||
collapsed={true}
|
||||
>
|
||||
<Cell
|
||||
width={calculatedActualFieldWidth}
|
||||
style={{ ...commonCellStyle, paddingLeft: '10px' }}
|
||||
plain
|
||||
>
|
||||
<Text style={{ whiteSpace: 'nowrap', fontSize: 13 }}>
|
||||
{field.actualField}
|
||||
</Text>
|
||||
</Cell>
|
||||
value={field.actualField}
|
||||
width={75}
|
||||
style={{ paddingLeft: '10px', height: '100%', border: 0 }}
|
||||
/>
|
||||
|
||||
<Cell width={arrowCellWidth} style={iconCellStyle} plain>
|
||||
<View
|
||||
<Text>
|
||||
<SvgRightArrow2
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<SvgRightArrow2
|
||||
style={{
|
||||
width: arrowIconWidth,
|
||||
height: 15,
|
||||
color: theme.tableText,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Cell>
|
||||
|
||||
<Cell
|
||||
width={calculatedSelectWidth}
|
||||
style={{ ...iconCellStyle, ...selectStyle }}
|
||||
plain
|
||||
>
|
||||
<Select
|
||||
aria-label={t('Synced field to map to {{field}}', {
|
||||
field: field.actualField,
|
||||
})}
|
||||
options={field.syncFields.map(({ field }) => [
|
||||
field,
|
||||
field,
|
||||
])}
|
||||
value={mapping.get(field.actualField)}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
onChange={newValue => {
|
||||
if (newValue) setMapping(field.actualField, newValue);
|
||||
width: 15,
|
||||
height: 15,
|
||||
color: theme.tableText,
|
||||
marginRight: '20px',
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
</Text>
|
||||
|
||||
<Cell width={equalsCellWidth} style={iconCellStyle} plain>
|
||||
<View
|
||||
<Select
|
||||
aria-label={t('Synced field to map to {{field}}', {
|
||||
field: field.actualField,
|
||||
})}
|
||||
options={field.syncFields.map(({ field }) => [field, field])}
|
||||
value={mapping.get(field.actualField)}
|
||||
style={{
|
||||
width: 290,
|
||||
}}
|
||||
onChange={newValue => {
|
||||
if (newValue) setMapping(field.actualField, newValue);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text>
|
||||
<SvgEquals
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 12,
|
||||
height: 12,
|
||||
color: theme.tableText,
|
||||
marginLeft: '20px',
|
||||
}}
|
||||
>
|
||||
<SvgEquals
|
||||
style={{
|
||||
width: equalsIconWidth,
|
||||
height: 12,
|
||||
color: theme.tableText,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</Cell>
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Cell
|
||||
value={
|
||||
@@ -220,7 +155,7 @@ export function FieldMapping({
|
||||
)?.example
|
||||
}
|
||||
width="flex"
|
||||
style={{ ...commonCellStyle, paddingLeft: 0 }}
|
||||
style={{ paddingLeft: '10px', height: '100%', border: 0 }}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
defaultMappings,
|
||||
type Mappings,
|
||||
mappingsFromString,
|
||||
mappingsToString,
|
||||
} from 'loot-core/server/util/custom-sync-mapping';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
|
||||
import {
|
||||
type TransactionDirection,
|
||||
type MappableFieldWithExample,
|
||||
getFields,
|
||||
} from './EditSyncAccount';
|
||||
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useTransactions } from '@desktop-client/hooks/useTransactions';
|
||||
|
||||
export function useBankSyncAccountSettings(accountId: string) {
|
||||
const [savedMappings = mappingsToString(defaultMappings), setSavedMappings] =
|
||||
useSyncedPref(`custom-sync-mappings-${accountId}`);
|
||||
const [savedImportNotes = true, setSavedImportNotes] = useSyncedPref(
|
||||
`sync-import-notes-${accountId}`,
|
||||
);
|
||||
const [savedImportPending = true, setSavedImportPending] = useSyncedPref(
|
||||
`sync-import-pending-${accountId}`,
|
||||
);
|
||||
const [savedReimportDeleted = true, setSavedReimportDeleted] = useSyncedPref(
|
||||
`sync-reimport-deleted-${accountId}`,
|
||||
);
|
||||
const [savedImportTransactions = true, setSavedImportTransactions] =
|
||||
useSyncedPref(`sync-import-transactions-${accountId}`);
|
||||
|
||||
const [transactionDirection, setTransactionDirection] =
|
||||
useState<TransactionDirection>('payment');
|
||||
const [importPending, setImportPending] = useState(
|
||||
String(savedImportPending) === 'true',
|
||||
);
|
||||
const [importNotes, setImportNotes] = useState(
|
||||
String(savedImportNotes) === 'true',
|
||||
);
|
||||
const [reimportDeleted, setReimportDeleted] = useState(
|
||||
String(savedReimportDeleted) === 'true',
|
||||
);
|
||||
const [mappings, setMappings] = useState<Mappings>(
|
||||
mappingsFromString(savedMappings),
|
||||
);
|
||||
const [importTransactions, setImportTransactions] = useState(
|
||||
String(savedImportTransactions) === 'true',
|
||||
);
|
||||
|
||||
const transactionQuery = q('transactions')
|
||||
.filter({
|
||||
account: accountId,
|
||||
amount: transactionDirection === 'payment' ? { $lte: 0 } : { $gt: 0 },
|
||||
raw_synced_data: { $ne: null },
|
||||
})
|
||||
.options({ splits: 'none' })
|
||||
.select('*');
|
||||
|
||||
const { transactions } = useTransactions({
|
||||
query: transactionQuery,
|
||||
});
|
||||
|
||||
const data = transactions?.[0]?.raw_synced_data;
|
||||
let exampleTransaction;
|
||||
if (data) {
|
||||
try {
|
||||
exampleTransaction = JSON.parse(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse transaction data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const fields: MappableFieldWithExample[] = exampleTransaction
|
||||
? getFields(exampleTransaction)
|
||||
: [];
|
||||
|
||||
const saveSettings = () => {
|
||||
const mappingsStr = mappingsToString(mappings);
|
||||
setSavedMappings(mappingsStr);
|
||||
setSavedImportPending(String(importPending));
|
||||
setSavedImportNotes(String(importNotes));
|
||||
setSavedReimportDeleted(String(reimportDeleted));
|
||||
setSavedImportTransactions(String(importTransactions));
|
||||
};
|
||||
|
||||
const setMapping = (field: string, value: string) => {
|
||||
setMappings(prev => {
|
||||
const updated = new Map(prev);
|
||||
const directionMap = updated.get(transactionDirection);
|
||||
if (directionMap) {
|
||||
const newDirectionMap = new Map(directionMap);
|
||||
newDirectionMap.set(field, value);
|
||||
updated.set(transactionDirection, newDirectionMap);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
transactionDirection,
|
||||
setTransactionDirection,
|
||||
importPending,
|
||||
setImportPending,
|
||||
importNotes,
|
||||
setImportNotes,
|
||||
reimportDeleted,
|
||||
setReimportDeleted,
|
||||
importTransactions,
|
||||
setImportTransactions,
|
||||
mappings,
|
||||
setMapping,
|
||||
exampleTransaction,
|
||||
fields,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import {
|
||||
SvgAdd,
|
||||
SvgCog,
|
||||
SvgCreditCard,
|
||||
SvgPiggyBank,
|
||||
SvgReports,
|
||||
SvgStoreFront,
|
||||
@@ -26,10 +25,7 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
|
||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
|
||||
const COLUMN_COUNT = 3;
|
||||
const PILL_HEIGHT = 15;
|
||||
@@ -44,9 +40,6 @@ export const MOBILE_NAV_HEIGHT = ROW_HEIGHT + PILL_HEIGHT;
|
||||
export function MobileNavTabs() {
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const isUsingServer =
|
||||
syncServerStatus !== 'no-server' || Platform.isPlaywright;
|
||||
const [navbarState, setNavbarState] = useState<'default' | 'open' | 'hidden'>(
|
||||
'default',
|
||||
);
|
||||
@@ -141,16 +134,6 @@ export function MobileNavTabs() {
|
||||
style: navTabStyle,
|
||||
Icon: SvgTuning,
|
||||
},
|
||||
...(isUsingServer
|
||||
? [
|
||||
{
|
||||
name: t('Bank Sync'),
|
||||
path: '/bank-sync',
|
||||
style: navTabStyle,
|
||||
Icon: SvgCreditCard,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t('Settings'),
|
||||
path: '/settings',
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
|
||||
|
||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||
|
||||
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
|
||||
|
||||
type BankSyncAccountsListProps = {
|
||||
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
|
||||
syncSourceReadable: Record<SyncProviders, string>;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
};
|
||||
|
||||
export function BankSyncAccountsList({
|
||||
groupedAccounts,
|
||||
syncSourceReadable,
|
||||
onAction,
|
||||
}: BankSyncAccountsListProps) {
|
||||
const allAccounts = Object.values(groupedAccounts).flat();
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.pageTextSubdued,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Trans>No accounts found matching your search.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
|
||||
>
|
||||
{(
|
||||
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
|
||||
).map(([provider, accounts]) => (
|
||||
<div key={provider}>
|
||||
{shouldShowProviderHeaders && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 40,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextLight,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{syncSourceReadable[provider]}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{accounts.map(account => (
|
||||
<BankSyncAccountsListItem
|
||||
key={account.id}
|
||||
account={account}
|
||||
onAction={onAction}
|
||||
isLinked={!!account.account_sync_source}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import { tsToRelativeTime } from 'loot-core/shared/util';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
|
||||
type BankSyncAccountsListItemProps = {
|
||||
account: AccountEntity;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
isLinked: boolean;
|
||||
};
|
||||
|
||||
export function BankSyncAccountsListItem({
|
||||
account,
|
||||
onAction,
|
||||
isLinked,
|
||||
}: BankSyncAccountsListItemProps) {
|
||||
const locale = useLocale();
|
||||
|
||||
const lastSyncString = isLinked
|
||||
? tsToRelativeTime(account.last_sync, locale, {
|
||||
capitalize: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
data-testid="bank-sync-account"
|
||||
direction="row"
|
||||
align="center"
|
||||
spacing={12}
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.tableBorder,
|
||||
borderBottomStyle: 'solid',
|
||||
padding: 16,
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => onAction(account, isLinked ? 'edit' : 'link')}
|
||||
>
|
||||
<Stack spacing={1} style={{ flex: 1 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: theme.tableText,
|
||||
}}
|
||||
>
|
||||
{account.name}
|
||||
</Text>
|
||||
{isLinked && account.bankName && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
>
|
||||
{account.bankName}
|
||||
</Text>
|
||||
)}
|
||||
{isLinked && lastSyncString && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
}}
|
||||
data-vrt-mask
|
||||
>
|
||||
<Trans>Last sync: {{ time: lastSyncString }}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
padding: '5px 10px',
|
||||
backgroundColor: theme.noticeBackground,
|
||||
border: '1px solid ' + theme.noticeBackground,
|
||||
color: theme.noticeTextDark,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isLinked ? <Trans>Edit</Trans> : <Trans>Link account</Trans>}
|
||||
</span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { BankSyncCheckboxOptions } from '@desktop-client/components/banksync/BankSyncCheckboxOptions';
|
||||
import { FieldMapping } from '@desktop-client/components/banksync/FieldMapping';
|
||||
import { useBankSyncAccountSettings } from '@desktop-client/components/banksync/useBankSyncAccountSettings';
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { useAccount } from '@desktop-client/hooks/useAccount';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function MobileBankSyncAccountEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { accountId } = useParams<{ accountId: string }>();
|
||||
const account = useAccount(accountId!);
|
||||
|
||||
const {
|
||||
transactionDirection,
|
||||
setTransactionDirection,
|
||||
importPending,
|
||||
setImportPending,
|
||||
importNotes,
|
||||
setImportNotes,
|
||||
reimportDeleted,
|
||||
setReimportDeleted,
|
||||
importTransactions,
|
||||
setImportTransactions,
|
||||
mappings,
|
||||
setMapping,
|
||||
fields,
|
||||
saveSettings,
|
||||
} = useBankSyncAccountSettings(accountId!);
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/bank-sync');
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
saveSettings();
|
||||
navigate('/bank-sync');
|
||||
};
|
||||
|
||||
const handleUnlink = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-unlink-account',
|
||||
options: {
|
||||
accountName: account?.name || '',
|
||||
isViewBankSyncSettings: true,
|
||||
onUnlink: () => {
|
||||
if (accountId) {
|
||||
dispatch(unlinkAccount({ id: accountId }));
|
||||
navigate('/bank-sync');
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
<MobilePageHeader
|
||||
title={t('Account not found')}
|
||||
leftContent={<MobileBackButton onPress={handleCancel} />}
|
||||
/>
|
||||
}
|
||||
padding={0}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Account not found</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
const mapping =
|
||||
mappings.get(transactionDirection) ?? new Map<string, string>();
|
||||
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
<MobilePageHeader
|
||||
title={account.name}
|
||||
leftContent={<MobileBackButton onPress={handleCancel} />}
|
||||
/>
|
||||
}
|
||||
padding={0}
|
||||
>
|
||||
<View style={{ flex: 1, backgroundColor: theme.mobilePageBackground }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ fontSize: 15, marginBottom: 10 }}>
|
||||
<Trans>Field mapping</Trans>
|
||||
</Text>
|
||||
|
||||
<FieldMapping
|
||||
transactionDirection={transactionDirection}
|
||||
setTransactionDirection={setTransactionDirection}
|
||||
fields={fields}
|
||||
mapping={mapping}
|
||||
setMapping={setMapping}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15, marginTop: 20, marginBottom: 10 }}>
|
||||
<Trans>Options</Trans>
|
||||
</Text>
|
||||
|
||||
<BankSyncCheckboxOptions
|
||||
importPending={importPending}
|
||||
setImportPending={setImportPending}
|
||||
importNotes={importNotes}
|
||||
setImportNotes={setImportNotes}
|
||||
reimportDeleted={reimportDeleted}
|
||||
setReimportDeleted={setReimportDeleted}
|
||||
importTransactions={importTransactions}
|
||||
setImportTransactions={setImportTransactions}
|
||||
helpMode="mobile"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
padding: 16,
|
||||
paddingTop: 12,
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
}}
|
||||
onPress={handleUnlink}
|
||||
>
|
||||
<Trans>Unlink account</Trans>
|
||||
</Button>
|
||||
|
||||
<Stack direction="row" style={{ gap: 10 }}>
|
||||
<Button onPress={handleCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" onPress={handleSave}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
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 {
|
||||
type BankSyncProviders,
|
||||
type AccountEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { BankSyncAccountsList } from './BankSyncAccountsList';
|
||||
|
||||
import { Search } from '@desktop-client/components/common/Search';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
|
||||
const useSyncSourceReadable = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const syncSourceReadable: Record<SyncProviders, string> = {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
unlinked: t('Unlinked'),
|
||||
};
|
||||
|
||||
return { syncSourceReadable };
|
||||
};
|
||||
|
||||
export function MobileBankSyncPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { syncSourceReadable } = useSyncSourceReadable();
|
||||
const accounts = useAccounts();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const openAccounts = useMemo(
|
||||
() => accounts.filter(a => !a.closed),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const groupedAccounts = useMemo(() => {
|
||||
const unsorted = openAccounts.reduce(
|
||||
(acc, a) => {
|
||||
const syncSource = a.account_sync_source ?? 'unlinked';
|
||||
acc[syncSource] = acc[syncSource] || [];
|
||||
acc[syncSource].push(a);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
|
||||
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
|
||||
if (keyA === 'unlinked') return 1;
|
||||
if (keyB === 'unlinked') return -1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
return sortedKeys.reduce(
|
||||
(sorted, key) => {
|
||||
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
|
||||
return sorted;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
}, [openAccounts]);
|
||||
|
||||
const filteredGroupedAccounts = useMemo(() => {
|
||||
if (!filter) return groupedAccounts;
|
||||
|
||||
const filterLower = filter.toLowerCase();
|
||||
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
|
||||
SyncProviders,
|
||||
AccountEntity[]
|
||||
>;
|
||||
|
||||
Object.entries(groupedAccounts).forEach(([provider, accounts]) => {
|
||||
const filteredAccounts = accounts.filter(
|
||||
account =>
|
||||
account.name.toLowerCase().includes(filterLower) ||
|
||||
account.bankName?.toLowerCase().includes(filterLower),
|
||||
);
|
||||
if (filteredAccounts.length > 0) {
|
||||
filtered[provider as SyncProviders] = filteredAccounts;
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [groupedAccounts, filter]);
|
||||
|
||||
const onAction = useCallback(
|
||||
(account: AccountEntity, action: 'link' | 'edit') => {
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
navigate(`/bank-sync/account/${account.id}/edit`);
|
||||
break;
|
||||
case 'link':
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'add-account',
|
||||
options: { upgradingAccountId: account.id },
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigate, dispatch],
|
||||
);
|
||||
|
||||
const onSearchChange = useCallback((value: string) => {
|
||||
setFilter(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Page header={<MobilePageHeader title={t('Bank Sync')} />} padding={0}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
placeholder={t('Filter accounts…')}
|
||||
value={filter}
|
||||
onChange={onSearchChange}
|
||||
width="100%"
|
||||
height={styles.mobileMinHeight}
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderColor: theme.formInputBorder,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{openAccounts.length === 0 ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 40,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: theme.pageTextSubdued,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
To use the bank syncing features, you must first add an account.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<BankSyncAccountsList
|
||||
groupedAccounts={filteredGroupedAccounts}
|
||||
syncSourceReadable={syncSourceReadable}
|
||||
onAction={onAction}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ListBoxSection,
|
||||
Header,
|
||||
Collection,
|
||||
Virtualizer,
|
||||
ListLayout,
|
||||
} from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -160,76 +158,64 @@ export function TransactionList({
|
||||
aria-label={t('Loading transactions...')}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Virtualizer
|
||||
layout={ListLayout}
|
||||
layoutOptions={{
|
||||
estimatedRowHeight: ROW_HEIGHT,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<ListBox
|
||||
aria-label={t('Transaction list')}
|
||||
selectionMode={
|
||||
selectedTransactions.size > 0 ? 'multiple' : 'single'
|
||||
}
|
||||
selectedKeys={selectedTransactions}
|
||||
dependencies={[selectedTransactions]}
|
||||
renderEmptyState={() =>
|
||||
!isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Trans>No transactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
items={sections}
|
||||
>
|
||||
{section => (
|
||||
<ListBoxSection>
|
||||
<Header
|
||||
style={{
|
||||
...styles.smallText,
|
||||
backgroundColor: theme.pageBackground,
|
||||
color: theme.tableHeaderText,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{monthUtils.format(section.date, 'MMMM dd, yyyy', locale)}
|
||||
</Header>
|
||||
<Collection
|
||||
items={section.transactions.filter(
|
||||
t => !isPreviewId(t.id) || !t.is_child,
|
||||
)}
|
||||
>
|
||||
{transaction => (
|
||||
<TransactionListItem
|
||||
key={transaction.id}
|
||||
value={transaction}
|
||||
onPress={trans => onTransactionPress(trans)}
|
||||
onLongPress={trans => onTransactionPress(trans, true)}
|
||||
/>
|
||||
)}
|
||||
</Collection>
|
||||
</ListBoxSection>
|
||||
)}
|
||||
</ListBox>
|
||||
</Virtualizer>
|
||||
</View>
|
||||
<ListBox
|
||||
aria-label={t('Transaction list')}
|
||||
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
|
||||
selectedKeys={selectedTransactions}
|
||||
dependencies={[selectedTransactions]}
|
||||
renderEmptyState={() =>
|
||||
!isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Trans>No transactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
items={sections}
|
||||
>
|
||||
{section => (
|
||||
<ListBoxSection>
|
||||
<Header
|
||||
style={{
|
||||
...styles.smallText,
|
||||
backgroundColor: theme.pageBackground,
|
||||
color: theme.tableHeaderText,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
width: '100%',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{monthUtils.format(section.date, 'MMMM dd, yyyy', locale)}
|
||||
</Header>
|
||||
<Collection
|
||||
items={section.transactions.filter(
|
||||
t => !isPreviewId(t.id) || !t.is_child,
|
||||
)}
|
||||
>
|
||||
{transaction => (
|
||||
<TransactionListItem
|
||||
key={transaction.id}
|
||||
value={transaction}
|
||||
onPress={trans => onTransactionPress(trans)}
|
||||
onLongPress={trans => onTransactionPress(trans, true)}
|
||||
/>
|
||||
)}
|
||||
</Collection>
|
||||
</ListBoxSection>
|
||||
)}
|
||||
</ListBox>
|
||||
|
||||
{isLoadingMore && (
|
||||
<Loading
|
||||
@@ -632,27 +618,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
|
||||
|
||||
@@ -7,9 +7,9 @@ import React, {
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Checkbox } from './index';
|
||||
import { Checkbox } from '@desktop-client/components/forms';
|
||||
|
||||
type LabeledCheckboxProps = {
|
||||
type CheckboxOptionProps = {
|
||||
id: string;
|
||||
checked?: ComponentProps<typeof Checkbox>['checked'];
|
||||
disabled?: ComponentProps<typeof Checkbox>['disabled'];
|
||||
@@ -18,14 +18,14 @@ type LabeledCheckboxProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function LabeledCheckbox({
|
||||
export function CheckboxOption({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
children,
|
||||
style,
|
||||
}: LabeledCheckboxProps) {
|
||||
}: CheckboxOptionProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -19,6 +19,7 @@ import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type ParseFileOptions } from 'loot-core/server/transactions/import/parse-file';
|
||||
import { amountToInteger } from 'loot-core/shared/util';
|
||||
|
||||
import { CheckboxOption } from './CheckboxOption';
|
||||
import { DateFormatSelect } from './DateFormatSelect';
|
||||
import { FieldMappings } from './FieldMappings';
|
||||
import { InOutOption } from './InOutOption';
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { SectionLabel } from '@desktop-client/components/forms';
|
||||
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
|
||||
import {
|
||||
TableHeader,
|
||||
TableWithNavigator,
|
||||
@@ -901,7 +901,7 @@ export function ImportTransactionsModal({
|
||||
)}
|
||||
|
||||
{isOfxFile(filetype) && (
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_fallback_missing_payee"
|
||||
checked={fallbackMissingPayeeToMemo}
|
||||
onChange={() => {
|
||||
@@ -909,11 +909,11 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Use Memo as a fallback for empty Payees</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{filetype !== 'csv' && (
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="import_notes"
|
||||
checked={importNotes}
|
||||
onChange={() => {
|
||||
@@ -921,11 +921,11 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Import notes from file</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
@@ -933,7 +933,7 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Merge with existing transactions</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{/*Import Options */}
|
||||
@@ -1005,7 +1005,7 @@ export function ImportTransactionsModal({
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
</label>
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_has_header"
|
||||
checked={hasHeaderRow}
|
||||
onChange={() => {
|
||||
@@ -1013,8 +1013,8 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>File has header row</Trans>
|
||||
</LabeledCheckbox>
|
||||
<LabeledCheckbox
|
||||
</CheckboxOption>
|
||||
<CheckboxOption
|
||||
id="clear_on_import"
|
||||
checked={clearOnImport}
|
||||
onChange={() => {
|
||||
@@ -1022,8 +1022,8 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Clear transactions on import</Trans>
|
||||
</LabeledCheckbox>
|
||||
<LabeledCheckbox
|
||||
</CheckboxOption>
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
@@ -1031,7 +1031,7 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Merge with existing transactions</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1039,7 +1039,7 @@ export function ImportTransactionsModal({
|
||||
|
||||
<View style={{ marginRight: 10, gap: 5 }}>
|
||||
<SectionLabel title={t('AMOUNT OPTIONS')} />
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_flip"
|
||||
checked={flipAmount}
|
||||
onChange={() => {
|
||||
@@ -1047,7 +1047,7 @@ export function ImportTransactionsModal({
|
||||
}}
|
||||
>
|
||||
<Trans>Flip amount</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
<MultiplierOption
|
||||
multiplierEnabled={multiplierEnabled}
|
||||
multiplierAmount={multiplierAmount}
|
||||
@@ -1059,7 +1059,7 @@ export function ImportTransactionsModal({
|
||||
/>
|
||||
{filetype === 'csv' && (
|
||||
<>
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_split"
|
||||
checked={splitMode}
|
||||
onChange={() => {
|
||||
@@ -1069,7 +1069,7 @@ export function ImportTransactionsModal({
|
||||
<Trans>
|
||||
Split amount into separate inflow/outflow columns
|
||||
</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
<InOutOption
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
|
||||
import { CheckboxOption } from './CheckboxOption';
|
||||
|
||||
type InOutOptionProps = {
|
||||
inOutMode: boolean;
|
||||
@@ -25,7 +25,7 @@ export function InOutOption({
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', gap: 10, height: 28 }}>
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="form_inOut"
|
||||
checked={inOutMode}
|
||||
disabled={disabled}
|
||||
@@ -34,7 +34,7 @@ export function InOutOption({
|
||||
{inOutMode
|
||||
? t('In/Out outflow value')
|
||||
: t('Select column to specify if amount goes in/out')}
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
{inOutMode && (
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { LabeledCheckbox } from '@desktop-client/components/forms/LabeledCheckbox';
|
||||
import { CheckboxOption } from './CheckboxOption';
|
||||
|
||||
type MultiplierOptionProps = {
|
||||
multiplierEnabled: boolean;
|
||||
multiplierAmount: string;
|
||||
onToggle: ComponentProps<typeof LabeledCheckbox>['onChange'];
|
||||
onToggle: ComponentProps<typeof CheckboxOption>['onChange'];
|
||||
onChangeAmount: (newValue: string) => void;
|
||||
};
|
||||
|
||||
@@ -22,13 +22,13 @@ export function MultiplierOption({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', gap: 10, height: 28 }}>
|
||||
<LabeledCheckbox
|
||||
<CheckboxOption
|
||||
id="add_multiplier"
|
||||
checked={multiplierEnabled}
|
||||
onChange={onToggle}
|
||||
>
|
||||
<Trans>Multiply amount</Trans>
|
||||
</LabeledCheckbox>
|
||||
</CheckboxOption>
|
||||
<Input
|
||||
type="text"
|
||||
style={{ display: multiplierEnabled ? 'inherit' : 'none' }}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -8,5 +8,3 @@ export { MobileRuleEditPage as RuleEdit } from '../mobile/rules/MobileRuleEditPa
|
||||
|
||||
export { CategoryPage as Category } from '../mobile/budget/CategoryPage';
|
||||
export { MobilePayeesPage as Payees } from '../mobile/payees/MobilePayeesPage';
|
||||
export { MobileBankSyncPage as BankSync } from '../mobile/banksync/MobileBankSyncPage';
|
||||
export { MobileBankSyncAccountEditPage as BankSyncAccountEdit } from '../mobile/banksync/MobileBankSyncAccountEditPage';
|
||||
|
||||
@@ -10,7 +10,6 @@ export { Account } from '../accounts/Account';
|
||||
export { ManageRulesPage as Rules } from '../ManageRulesPage';
|
||||
export { ManageRulesPage as RuleEdit } from '../ManageRulesPage';
|
||||
export { ManagePayeesPage as Payees } from '../payees/ManagePayeesPage';
|
||||
export { BankSync } from '../banksync';
|
||||
|
||||
export { UserDirectoryPage } from '../admin/UserDirectory/UserDirectoryPage';
|
||||
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||