Compare commits
45 Commits
split-paye
...
react-aria
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b57aefe1 | ||
|
|
e25683f130 | ||
|
|
496c76c7f9 | ||
|
|
b7d4964539 | ||
|
|
7479df359a | ||
|
|
b1b14d0813 | ||
|
|
b710b9675e | ||
|
|
f8fb4a9ba7 | ||
|
|
9f738956d7 | ||
|
|
dc9ce974a5 | ||
|
|
27974c63fd | ||
|
|
f81c452ba5 | ||
|
|
7072674111 | ||
|
|
16e887c917 | ||
|
|
572033debe | ||
|
|
b85f9102ce | ||
|
|
942aebedd0 | ||
|
|
32d830440a | ||
|
|
4575616961 | ||
|
|
4a0e2ea306 | ||
|
|
14ec9a9089 | ||
|
|
e91b4070aa | ||
|
|
6dd34b0c63 | ||
|
|
ab4639f48f | ||
|
|
aa3cbd881b | ||
|
|
8681c9c3e6 | ||
|
|
9ec9aef632 | ||
|
|
3be7dd753d | ||
|
|
259e84cea5 | ||
|
|
f9014f0e19 | ||
|
|
e59f5c9af8 | ||
|
|
771c01c8b4 | ||
|
|
9f72b43826 | ||
|
|
ec3475d834 | ||
|
|
5ea9c587a8 | ||
|
|
1e38055376 | ||
|
|
0ee9126820 | ||
|
|
9e455e4c1e | ||
|
|
d77b54f27b | ||
|
|
ff36d1efbe | ||
|
|
cbbbaf65cf | ||
|
|
f129b07dc9 | ||
|
|
f1caf21deb | ||
|
|
a28ea6be8f | ||
|
|
f36c5e002b |
37
.github/workflows/trafico.yml
vendored
@@ -1,37 +0,0 @@
|
||||
##########################################################################################
|
||||
# WARNING! This workflow uses the 'pull_request_target' event. That mans that it will #
|
||||
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
|
||||
# a fork. This is necessary to get access to a GitHub token that can modify the PR. #
|
||||
# Be VERY CAREFUL about adding things to this workflow, since forks can inject #
|
||||
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
|
||||
# code execution in this workflow could lead to a compromise of the main repo. #
|
||||
##########################################################################################
|
||||
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
|
||||
##########################################################################################
|
||||
|
||||
name: Trafico Reviews
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- closed
|
||||
- reopened
|
||||
- synchronize
|
||||
- edited
|
||||
- review_requested
|
||||
- review_request_removed
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
manage-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actualbudget/trafico@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
27
.github/workflows/wip.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Add WIP
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
add_wip_prefix:
|
||||
if: |
|
||||
join(github.event.pull_request.requested_reviewers) == ''
|
||||
&& !contains(github.event.pull_request.title, 'WIP')
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'WIP')
|
||||
&& github.event.pull_request.draft != true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Add WIP
|
||||
env:
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token
|
||||
gh pr edit ${{ github.event.pull_request.number }} -t "[WIP] ${TITLE}"
|
||||
@@ -58,6 +58,19 @@ describe('API CRUD operations', () => {
|
||||
await api.loadBudget(budgetName);
|
||||
});
|
||||
|
||||
// api: getBudgets
|
||||
test('getBudgets', async () => {
|
||||
const budgets = await api.getBudgets();
|
||||
expect(budgets).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'test-budget',
|
||||
name: 'Default Test Db',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getCategoryGroups, createCategoryGroup, updateCategoryGroup, deleteCategoryGroup
|
||||
test('CategoryGroups: successfully update category groups', async () => {
|
||||
const month = '2023-10';
|
||||
@@ -251,7 +264,7 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount
|
||||
//apis: createAccount, getAccounts, updateAccount, closeAccount, deleteAccount, reopenAccount, getAccountBalance
|
||||
test('Accounts: successfully complete account operators', async () => {
|
||||
const accountId1 = await api.createAccount(
|
||||
{ name: 'test-account1', offbudget: true },
|
||||
@@ -272,6 +285,9 @@ describe('API CRUD operations', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
expect(await api.getAccountBalance(accountId1)).toEqual(1000);
|
||||
expect(await api.getAccountBalance(accountId2)).toEqual(0);
|
||||
|
||||
await api.updateAccount(accountId1, { offbudget: false });
|
||||
await api.closeAccount(accountId1, accountId2, null);
|
||||
await api.deleteAccount(accountId2);
|
||||
@@ -569,6 +585,11 @@ describe('API CRUD operations', () => {
|
||||
});
|
||||
expect(addResult).toBe('ok');
|
||||
|
||||
expect(await api.getAccountBalance(accountId)).toEqual(200);
|
||||
expect(
|
||||
await api.getAccountBalance(accountId, new Date(2023, 10, 2)),
|
||||
).toEqual(0);
|
||||
|
||||
// confirm added transactions exist
|
||||
let transactions = await api.getTransactions(
|
||||
accountId,
|
||||
|
||||
@@ -31,6 +31,10 @@ export async function downloadBudget(syncId, { password }: { password? } = {}) {
|
||||
return send('api/download-budget', { syncId, password });
|
||||
}
|
||||
|
||||
export async function getBudgets() {
|
||||
return send('api/get-budgets');
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
return send('api/sync');
|
||||
}
|
||||
@@ -125,6 +129,10 @@ export function deleteAccount(id) {
|
||||
return send('api/account-delete', { id });
|
||||
}
|
||||
|
||||
export function getAccountBalance(id, cutoff?) {
|
||||
return send('api/account-balance', { id, cutoff });
|
||||
}
|
||||
|
||||
export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
}
|
||||
@@ -173,6 +181,10 @@ export function deletePayee(id) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function mergePayees(targetId, mergeIds) {
|
||||
return send('api/payees-merge', { targetId, mergeIds });
|
||||
}
|
||||
|
||||
export function getRules() {
|
||||
return send('api/rules-get');
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -47,6 +47,7 @@
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.9.17",
|
||||
"react": "18.2.0",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
||||
@@ -135,6 +135,14 @@ global.Actual = {
|
||||
},
|
||||
};
|
||||
|
||||
function inputFocused(e) {
|
||||
return (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
// Cmd/Ctrl+o
|
||||
@@ -144,11 +152,7 @@ document.addEventListener('keydown', e => {
|
||||
}
|
||||
// Cmd/Ctrl+z
|
||||
else if (e.key.toLowerCase() === 'z') {
|
||||
if (
|
||||
e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.isContentEditable
|
||||
) {
|
||||
if (inputFocused(e)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -160,5 +164,10 @@ document.addEventListener('keydown', e => {
|
||||
window.__actionsForMenu.undo();
|
||||
}
|
||||
}
|
||||
} else if (e.key === '?') {
|
||||
if (inputFocused(e)) {
|
||||
return;
|
||||
}
|
||||
window.__actionsForMenu.pushModal('keyboard-shortcuts');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import { ScrollProvider } from './ScrollProvider';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
import { SidebarProvider } from './sidebar/SidebarProvider';
|
||||
import { Titlebar, TitlebarProvider } from './Titlebar';
|
||||
import { Titlebar } from './Titlebar';
|
||||
|
||||
function NarrowNotSupported({
|
||||
redirectTo = '/budget',
|
||||
@@ -246,15 +246,13 @@ export function FinancesApp() {
|
||||
|
||||
return (
|
||||
<SpreadsheetProvider>
|
||||
<TitlebarProvider>
|
||||
<SidebarProvider>
|
||||
<BudgetMonthCountProvider>
|
||||
<DndProvider backend={Backend}>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
</DndProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
</SidebarProvider>
|
||||
</TitlebarProvider>
|
||||
<SidebarProvider>
|
||||
<BudgetMonthCountProvider>
|
||||
<DndProvider backend={Backend}>
|
||||
<ScrollProvider>{app}</ScrollProvider>
|
||||
</DndProvider>
|
||||
</BudgetMonthCountProvider>
|
||||
</SidebarProvider>
|
||||
</SpreadsheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg';
|
||||
import { GoCardlessInitialise } from './modals/GoCardlessInitialise';
|
||||
import { HoldBufferModal } from './modals/HoldBufferModal';
|
||||
import { ImportTransactions } from './modals/ImportTransactions';
|
||||
import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
|
||||
import { LoadBackup } from './modals/LoadBackup';
|
||||
import { ManageRulesModal } from './modals/ManageRulesModal';
|
||||
import { MergeUnusedPayees } from './modals/MergeUnusedPayees';
|
||||
@@ -53,7 +54,6 @@ import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenu
|
||||
import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts';
|
||||
import { SimpleFinInitialise } from './modals/SimpleFinInitialise';
|
||||
import { SingleInputModal } from './modals/SingleInputModal';
|
||||
import { SwitchBudgetTypeModal } from './modals/SwitchBudgetTypeModal';
|
||||
import { TransferModal } from './modals/TransferModal';
|
||||
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
|
||||
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||
@@ -96,6 +96,9 @@ export function Modals() {
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'keyboard-shortcuts':
|
||||
return <KeyboardShortcutModal modalProps={modalProps} />;
|
||||
|
||||
case 'import-transactions':
|
||||
return (
|
||||
<ImportTransactions
|
||||
@@ -423,15 +426,6 @@ export function Modals() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'switch-budget-type':
|
||||
return (
|
||||
<SwitchBudgetTypeModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onSwitch={options.onSwitch}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'account-menu':
|
||||
return (
|
||||
<AccountMenuModal
|
||||
@@ -623,7 +617,6 @@ export function Modals() {
|
||||
onAddCategoryGroup={options.onAddCategoryGroup}
|
||||
onToggleHiddenCategories={options.onToggleHiddenCategories}
|
||||
onSwitchBudgetFile={options.onSwitchBudgetFile}
|
||||
onSwitchBudgetType={options.onSwitchBudgetType}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { NotificationWithId } from 'loot-core/src/client/state-types/notifi
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { AnimatedLoading } from '../icons/AnimatedLoading';
|
||||
import { SvgDelete } from '../icons/v0';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { styles, theme, type CSSProperties } from '../style';
|
||||
|
||||
import { Button, ButtonWithLoading } from './common/Button';
|
||||
@@ -245,6 +246,7 @@ function Notification({
|
||||
|
||||
export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
const { removeNotification } = useActions();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const notifications = useSelector(
|
||||
(state: State) => state.notifications.notifications,
|
||||
);
|
||||
@@ -254,6 +256,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 13,
|
||||
left: isNarrowWidth ? 13 : undefined,
|
||||
zIndex: 10000,
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -13,10 +6,8 @@ import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { listen } from 'loot-core/src/platform/client/fetch';
|
||||
import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
|
||||
import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useFeatureFlag } from '../hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
@@ -33,10 +24,8 @@ import { theme, type CSSProperties, styles } from '../style';
|
||||
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
|
||||
import { AnimatedRefresh } from './AnimatedRefresh';
|
||||
import { MonthCountSelector } from './budget/MonthCountSelector';
|
||||
import { Button, ButtonWithLoading } from './common/Button';
|
||||
import { Button } from './common/Button';
|
||||
import { Link } from './common/Link';
|
||||
import { Paragraph } from './common/Paragraph';
|
||||
import { Popover } from './common/Popover';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { LoggedInUser } from './LoggedInUser';
|
||||
@@ -45,55 +34,6 @@ import { useSidebar } from './sidebar/SidebarProvider';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
|
||||
export const SWITCH_BUDGET_MESSAGE_TYPE = 'budget/switch-type';
|
||||
|
||||
type SwitchBudgetTypeMessage = {
|
||||
type: typeof SWITCH_BUDGET_MESSAGE_TYPE;
|
||||
payload: {
|
||||
newBudgetType: LocalPrefs['budgetType'];
|
||||
};
|
||||
};
|
||||
export type TitlebarMessage = SwitchBudgetTypeMessage;
|
||||
|
||||
type Listener = (msg: TitlebarMessage) => void;
|
||||
export type TitlebarContextValue = {
|
||||
sendEvent: (msg: TitlebarMessage) => void;
|
||||
subscribe: (listener: Listener) => () => void;
|
||||
};
|
||||
|
||||
export const TitlebarContext = createContext<TitlebarContextValue>({
|
||||
sendEvent() {
|
||||
throw new Error('TitlebarContext not initialized');
|
||||
},
|
||||
subscribe() {
|
||||
throw new Error('TitlebarContext not initialized');
|
||||
},
|
||||
});
|
||||
|
||||
type TitlebarProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function TitlebarProvider({ children }: TitlebarProviderProps) {
|
||||
const listeners = useRef<Listener[]>([]);
|
||||
|
||||
function sendEvent(msg: TitlebarMessage) {
|
||||
listeners.current.forEach(func => func(msg));
|
||||
}
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.current.push(listener);
|
||||
return () =>
|
||||
(listeners.current = listeners.current.filter(func => func !== listener));
|
||||
}
|
||||
|
||||
return (
|
||||
<TitlebarContext.Provider value={{ sendEvent, subscribe }}>
|
||||
{children}
|
||||
</TitlebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function UncategorizedButton() {
|
||||
const count: number | null = useSheetValue(queries.uncategorizedCount());
|
||||
if (count === null || count <= 0) {
|
||||
@@ -287,31 +227,6 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
|
||||
function BudgetTitlebar() {
|
||||
const [maxMonths, setMaxMonthsPref] = useGlobalPref('maxMonths');
|
||||
const [budgetType] = useLocalPref('budgetType');
|
||||
const { sendEvent } = useContext(TitlebarContext);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const reportBudgetEnabled = useFeatureFlag('reportBudget');
|
||||
|
||||
function onSwitchType() {
|
||||
setLoading(true);
|
||||
if (!loading) {
|
||||
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
|
||||
sendEvent({
|
||||
type: SWITCH_BUDGET_MESSAGE_TYPE,
|
||||
payload: {
|
||||
newBudgetType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [budgetType]);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
@@ -319,61 +234,6 @@ function BudgetTitlebar() {
|
||||
maxMonths={maxMonths || 1}
|
||||
onChange={value => setMaxMonthsPref(value)}
|
||||
/>
|
||||
{reportBudgetEnabled && (
|
||||
<View style={{ marginLeft: -5 }}>
|
||||
<ButtonWithLoading
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
loading={loading}
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '4px 7px',
|
||||
}}
|
||||
title="Learn more about budgeting"
|
||||
onClick={() => setShowPopover(true)}
|
||||
>
|
||||
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}
|
||||
</ButtonWithLoading>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
isOpen={showPopover}
|
||||
onOpenChange={() => setShowPopover(false)}
|
||||
style={{
|
||||
padding: 10,
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Paragraph>
|
||||
You are currently using a{' '}
|
||||
<Text style={{ fontWeight: 600 }}>
|
||||
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
|
||||
</Text>{' '}
|
||||
Switching will not lose any data and you can always switch back.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<ButtonWithLoading
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={onSwitchType}
|
||||
>
|
||||
Switch to a{' '}
|
||||
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
|
||||
</ButtonWithLoading>
|
||||
</Paragraph>
|
||||
<Paragraph isLast={true}>
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/experimental/report-budget"
|
||||
linkColor="muted"
|
||||
>
|
||||
How do these types of budgeting work?
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</Popover>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -416,11 +276,6 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
sidebar.setHidden(false);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={e => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
sidebar.setHidden(true);
|
||||
}
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
if (e.pointerType !== 'mouse') {
|
||||
sidebar.setHidden(!sidebar.hidden);
|
||||
|
||||
@@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { validForTransfer } from 'loot-core/client/transfer';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules';
|
||||
import {
|
||||
SchedulesProvider,
|
||||
useDefaultSchedulesQueryTransform,
|
||||
} from 'loot-core/src/client/data-hooks/schedules';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
@@ -1837,29 +1840,7 @@ export function Account() {
|
||||
const savedFiters = useFilters();
|
||||
const actionCreators = useActions();
|
||||
|
||||
const transform = useMemo(() => {
|
||||
const filterByAccount = queries.getAccountFilter(params.id, '_account');
|
||||
const filterByPayee = queries.getAccountFilter(
|
||||
params.id,
|
||||
'_payee.transfer_acct',
|
||||
);
|
||||
|
||||
return q => {
|
||||
q = q.filter({
|
||||
$and: [{ '_account.closed': false }],
|
||||
});
|
||||
if (params.id) {
|
||||
if (params.id === 'uncategorized') {
|
||||
q = q.filter({ next_date: null });
|
||||
} else {
|
||||
q = q.filter({
|
||||
$or: [filterByAccount, filterByPayee],
|
||||
});
|
||||
}
|
||||
}
|
||||
return q.orderBy({ next_date: 'desc' });
|
||||
};
|
||||
}, [params.id]);
|
||||
const transform = useDefaultSchedulesQueryTransform(params.id);
|
||||
|
||||
return (
|
||||
<SchedulesProvider transform={transform}>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { css } from 'glamor';
|
||||
import { SvgRemove } from '../../icons/v2';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Input } from '../common/Input';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
@@ -621,7 +621,12 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Remove autocomplete item"
|
||||
style={{ marginLeft: 1 }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
|
||||
@@ -366,9 +366,10 @@ export function PayeeAutocomplete({
|
||||
<AutocompleteFooter embedded={embedded}>
|
||||
{showMakeTransfer && (
|
||||
<Button
|
||||
type={focusTransferPayees ? 'menuSelected' : 'menu'}
|
||||
variant={focusTransferPayees ? 'menuSelected' : 'menu'}
|
||||
aria-label="Make transfer"
|
||||
style={showManagePayees && { marginBottom: 5 }}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
onUpdate?.(null, null);
|
||||
setFocusTransferPayees(!focusTransferPayees);
|
||||
}}
|
||||
@@ -377,7 +378,11 @@ export function PayeeAutocomplete({
|
||||
</Button>
|
||||
)}
|
||||
{showManagePayees && (
|
||||
<Button type="menu" onClick={() => onManagePayees()}>
|
||||
<Button
|
||||
variant="menu"
|
||||
aria-label="Manage payees"
|
||||
onPress={() => onManagePayees()}
|
||||
>
|
||||
Manage Payees
|
||||
</Button>
|
||||
)}
|
||||
@@ -420,7 +425,7 @@ export function CreatePayeeButton({
|
||||
data-testid="create-payee-button"
|
||||
style={{
|
||||
display: 'block',
|
||||
flexShrink: 0,
|
||||
flex: '1 0',
|
||||
color: highlighted
|
||||
? theme.menuAutoCompleteTextHover
|
||||
: theme.noticeTextMenu,
|
||||
|
||||
@@ -58,7 +58,13 @@ export function IncomeCategory({
|
||||
});
|
||||
|
||||
return (
|
||||
<Row innerRef={dropRef} collapsed={true}>
|
||||
<Row
|
||||
innerRef={dropRef}
|
||||
collapsed={true}
|
||||
style={{
|
||||
opacity: cat.hidden ? 0.5 : undefined,
|
||||
}}
|
||||
>
|
||||
<DropHighlight pos={dropPos} offset={{ top: 1 }} />
|
||||
|
||||
<SidebarCategory
|
||||
|
||||
@@ -106,11 +106,11 @@ export function SidebarCategory({
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
items={[
|
||||
{ name: 'rename', text: 'Rename' },
|
||||
!categoryGroup?.hidden && {
|
||||
name: 'toggle-visibility',
|
||||
text: category.hidden ? 'Show' : 'Hide',
|
||||
},
|
||||
{ name: 'rename', text: 'Rename' },
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -127,11 +127,11 @@ export function SidebarGroup({
|
||||
}}
|
||||
items={[
|
||||
{ name: 'add-category', text: 'Add category' },
|
||||
{ name: 'rename', text: 'Rename' },
|
||||
!group.is_income && {
|
||||
name: 'toggle-visibility',
|
||||
text: group.hidden ? 'Show' : 'Hide',
|
||||
},
|
||||
{ name: 'rename', text: 'Rename' },
|
||||
onDelete && { name: 'delete', text: 'Delete' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { memo, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import React, { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import {
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
deleteCategory,
|
||||
deleteGroup,
|
||||
getCategories,
|
||||
loadPrefs,
|
||||
moveCategory,
|
||||
moveCategoryGroup,
|
||||
pushModal,
|
||||
@@ -28,19 +27,13 @@ import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
|
||||
import {
|
||||
SWITCH_BUDGET_MESSAGE_TYPE,
|
||||
TitlebarContext,
|
||||
type TitlebarContextValue,
|
||||
type TitlebarMessage,
|
||||
} from '../Titlebar';
|
||||
|
||||
import { DynamicBudgetTable } from './DynamicBudgetTable';
|
||||
import * as report from './report/ReportComponents';
|
||||
import { ReportProvider } from './report/ReportContext';
|
||||
import * as rollover from './rollover/RolloverComponents';
|
||||
import { RolloverProvider } from './rollover/RolloverContext';
|
||||
import { prewarmAllMonths, prewarmMonth, switchBudgetType } from './util';
|
||||
import { prewarmAllMonths, prewarmMonth } from './util';
|
||||
|
||||
type ReportComponents = {
|
||||
SummaryComponent: typeof report.BudgetSummary;
|
||||
@@ -66,7 +59,6 @@ type BudgetInnerProps = {
|
||||
accountId?: string;
|
||||
reportComponents: ReportComponents;
|
||||
rolloverComponents: RolloverComponents;
|
||||
titlebar: TitlebarContextValue;
|
||||
};
|
||||
|
||||
function BudgetInner(props: BudgetInnerProps) {
|
||||
@@ -95,8 +87,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const { titlebar } = props;
|
||||
|
||||
async function run() {
|
||||
loadCategories();
|
||||
|
||||
@@ -132,8 +122,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
loadCategories();
|
||||
}
|
||||
}),
|
||||
|
||||
titlebar.subscribe(onTitlebarEvent),
|
||||
];
|
||||
|
||||
return () => {
|
||||
@@ -323,24 +311,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
setSummaryCollapsedPref(!summaryCollapsed);
|
||||
};
|
||||
|
||||
const onTitlebarEvent = async ({ type, payload }: TitlebarMessage) => {
|
||||
switch (type) {
|
||||
case SWITCH_BUDGET_MESSAGE_TYPE: {
|
||||
await switchBudgetType(
|
||||
payload.newBudgetType,
|
||||
spreadsheet,
|
||||
bounds,
|
||||
startMonth,
|
||||
async () => {
|
||||
dispatch(loadPrefs());
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
const { reportComponents, rolloverComponents } = props;
|
||||
|
||||
if (!initialized || !categoryGroups) {
|
||||
@@ -416,8 +386,6 @@ const RolloverBudgetSummary = memo<{ month: string }>(props => {
|
||||
RolloverBudgetSummary.displayName = 'RolloverBudgetSummary';
|
||||
|
||||
export function Budget() {
|
||||
const titlebar = useContext(TitlebarContext);
|
||||
|
||||
const reportComponents = useMemo<ReportComponents>(
|
||||
() => ({
|
||||
SummaryComponent: report.BudgetSummary,
|
||||
@@ -460,7 +428,6 @@ export function Budget() {
|
||||
<BudgetInner
|
||||
reportComponents={reportComponents}
|
||||
rolloverComponents={rolloverComponents}
|
||||
titlebar={titlebar}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SvgFilter } from '../../icons/v1';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
|
||||
export function CompactFiltersButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button type="bare" onClick={onClick}>
|
||||
<Button variant="bare" onPress={onClick}>
|
||||
<SvgFilter width={15} height={15} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
import { SvgDelete } from '../../icons/v0';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
@@ -18,6 +18,8 @@ import { Value } from '../rules/Value';
|
||||
import { FilterEditor } from './FiltersMenu';
|
||||
import { subfieldFromFilter } from './subfieldFromFilter';
|
||||
|
||||
let isDatepickerClick = false;
|
||||
|
||||
type FilterExpressionProps = {
|
||||
field: string | undefined;
|
||||
customName: string | undefined;
|
||||
@@ -43,6 +45,8 @@ export function FilterExpression({
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const field = subfieldFromFilter({ field: originalField, value });
|
||||
const displayField = mapField(field, options);
|
||||
const displayOp = friendlyOp(op, null);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -58,9 +62,10 @@ export function FilterExpression({
|
||||
>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
disabled={customName != null}
|
||||
onClick={() => setEditing(true)}
|
||||
variant="bare"
|
||||
aria-label={`${displayField} ${displayOp} ${value} filter`}
|
||||
isDisabled={customName != null}
|
||||
onPress={() => setEditing(true)}
|
||||
>
|
||||
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
|
||||
{customName ? (
|
||||
@@ -68,9 +73,9 @@ export function FilterExpression({
|
||||
) : (
|
||||
<>
|
||||
<Text style={{ color: theme.pageTextPositive }}>
|
||||
{mapField(field, options)}
|
||||
{displayField}
|
||||
</Text>{' '}
|
||||
<Text>{friendlyOp(op, null)}</Text>{' '}
|
||||
<Text>{displayOp}</Text>{' '}
|
||||
<Value
|
||||
value={value}
|
||||
field={field}
|
||||
@@ -85,7 +90,7 @@ export function FilterExpression({
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
|
||||
<Button variant="bare" onPress={onDelete} aria-label="Delete filter">
|
||||
<SvgDelete
|
||||
style={{
|
||||
width: 8,
|
||||
@@ -100,6 +105,21 @@ export function FilterExpression({
|
||||
placement="bottom start"
|
||||
isOpen={editing}
|
||||
onOpenChange={() => setEditing(false)}
|
||||
shouldCloseOnInteractOutside={element => {
|
||||
// Datepicker selections for some reason register 2x clicks
|
||||
// We want to keep the popover open after selecting a date.
|
||||
// So we ignore the "close" event on selection + the subsequent event.
|
||||
if (element instanceof HTMLElement && element.dataset.pikaYear) {
|
||||
isDatepickerClick = true;
|
||||
return false;
|
||||
}
|
||||
if (isDatepickerClick) {
|
||||
isDatepickerClick = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
style={{ width: 275, padding: 15, color: theme.menuItemText }}
|
||||
data-testid="filters-menu-tooltip"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { SvgFilter } from '../../icons/v1/Filter';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
|
||||
export function FiltersButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button type="bare" onClick={onClick} title="Filters">
|
||||
<Button variant="bare" onPress={onClick} aria-label="Filters">
|
||||
<SvgFilter style={{ width: 12, height: 12, marginRight: 5 }} /> Filter
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import { titleFirst } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Select } from '../common/Select';
|
||||
@@ -219,9 +219,9 @@ function ConfigureField({
|
||||
>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
variant="primary"
|
||||
aria-label="Apply"
|
||||
onPress={() => {
|
||||
onApply({
|
||||
field,
|
||||
op,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Input } from '../common/Input';
|
||||
import { Stack } from '../common/Stack';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -54,12 +54,10 @@ export function NameFilter({
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
type="primary"
|
||||
variant="primary"
|
||||
aria-label={adding ? 'Add' : 'Update'}
|
||||
style={{ marginTop: 18 }}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onAddUpdate();
|
||||
}}
|
||||
onPress={onAddUpdate}
|
||||
>
|
||||
{adding ? 'Add' : 'Update'}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { friendlyOp } from 'loot-core/src/shared/rules';
|
||||
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
|
||||
type OpButtonProps = {
|
||||
op: string;
|
||||
@@ -13,24 +13,30 @@ type OpButtonProps = {
|
||||
};
|
||||
|
||||
export function OpButton({ op, selected, style, onClick }: OpButtonProps) {
|
||||
const displayOp = friendlyOp(op);
|
||||
return (
|
||||
<Button
|
||||
type="bare"
|
||||
style={{
|
||||
backgroundColor: theme.pillBackground,
|
||||
variant="bare"
|
||||
aria-label={`${displayOp} op`}
|
||||
style={({ isHovered, isPressed }) => ({
|
||||
marginBottom: 5,
|
||||
...style,
|
||||
...(selected && {
|
||||
color: theme.buttonNormalSelectedText,
|
||||
'&,:hover,:active': {
|
||||
backgroundColor: theme.buttonNormalSelectedBackground,
|
||||
color: theme.buttonNormalSelectedText,
|
||||
},
|
||||
}),
|
||||
}}
|
||||
onClick={onClick}
|
||||
...(selected
|
||||
? {
|
||||
color: theme.pillTextSelected,
|
||||
backgroundColor: theme.pillBackgroundSelected,
|
||||
}
|
||||
: isHovered || isPressed
|
||||
? {
|
||||
backgroundColor: theme.pillBackgroundHover,
|
||||
}
|
||||
: {
|
||||
backgroundColor: theme.pillBackground,
|
||||
}),
|
||||
})}
|
||||
onPress={onClick}
|
||||
>
|
||||
{friendlyOp(op)}
|
||||
{displayOp}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type TransactionFilterEntity } from 'loot-core/types/models';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
@@ -161,9 +161,10 @@ export function SavedFilterMenuButton({
|
||||
{conditions.length > 0 && (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Saved filter menu"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -105,6 +105,17 @@ export const Checkbox = (props: CheckboxProps) => {
|
||||
content: ' ',
|
||||
},
|
||||
},
|
||||
':disabled': {
|
||||
border: '1px solid ' + theme.buttonNormalDisabledBorder,
|
||||
backgroundColor: theme.buttonNormalDisabledBorder,
|
||||
},
|
||||
':checked:disabled': {
|
||||
border: '1px solid ' + theme.buttonNormalDisabledBorder,
|
||||
backgroundColor: theme.buttonNormalDisabledBorder,
|
||||
'::after': {
|
||||
backgroundColor: theme.buttonNormalDisabledBorder,
|
||||
},
|
||||
},
|
||||
'&.focus-visible:focus': {
|
||||
'::before': {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState, useRef, type CSSProperties } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
@@ -12,6 +13,12 @@ import {
|
||||
pushModal,
|
||||
} from 'loot-core/client/actions';
|
||||
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
|
||||
import {
|
||||
type File,
|
||||
type LocalFile,
|
||||
type SyncableLocalFile,
|
||||
type SyncedLocalFile,
|
||||
} from 'loot-core/types/file';
|
||||
|
||||
import { useInitialMount } from '../../hooks/useInitialMount';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
@@ -32,7 +39,7 @@ import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
|
||||
function getFileDescription(file) {
|
||||
function getFileDescription(file: File) {
|
||||
if (file.state === 'unknown') {
|
||||
return (
|
||||
'This is a cloud-based file but its state is unknown because you ' +
|
||||
@@ -50,8 +57,14 @@ function getFileDescription(file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FileMenu({ onDelete, onClose }) {
|
||||
function onMenuSelect(type) {
|
||||
function FileMenu({
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
onDelete: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
function onMenuSelect(type: string) {
|
||||
onClose();
|
||||
|
||||
switch (type) {
|
||||
@@ -83,7 +96,7 @@ function FileMenu({ onDelete, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FileMenuButton({ state, onDelete }) {
|
||||
function FileMenuButton({ onDelete }: { onDelete: () => void }) {
|
||||
const triggerRef = useRef(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
@@ -106,17 +119,13 @@ function FileMenuButton({ state, onDelete }) {
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
>
|
||||
<FileMenu
|
||||
state={state}
|
||||
onDelete={onDelete}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
/>
|
||||
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
|
||||
</Popover>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function FileState({ file }) {
|
||||
function FileState({ file }: { file: File }) {
|
||||
let Icon;
|
||||
let status;
|
||||
let color;
|
||||
@@ -164,10 +173,20 @@ function FileState({ file }) {
|
||||
);
|
||||
}
|
||||
|
||||
function File({ file, quickSwitchMode, onSelect, onDelete }) {
|
||||
function FileItem({
|
||||
file,
|
||||
quickSwitchMode,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
file: File;
|
||||
quickSwitchMode: boolean;
|
||||
onSelect: (file: File) => void;
|
||||
onDelete: (file: File) => void;
|
||||
}) {
|
||||
const selecting = useRef(false);
|
||||
|
||||
async function _onSelect(file) {
|
||||
async function _onSelect(file: File) {
|
||||
// Never allow selecting the file while uploading/downloading, and
|
||||
// make sure to never allow duplicate clicks
|
||||
if (!selecting.current) {
|
||||
@@ -180,7 +199,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
|
||||
return (
|
||||
<View
|
||||
onClick={() => _onSelect(file)}
|
||||
title={getFileDescription(file)}
|
||||
title={getFileDescription(file) || ''}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
@@ -193,7 +212,7 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
backgroundColor: theme.hover,
|
||||
backgroundColor: theme.menuItemBackgroundHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -219,15 +238,27 @@ function File({ file, quickSwitchMode, onSelect, onDelete }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!quickSwitchMode && (
|
||||
<FileMenuButton state={file.state} onDelete={() => onDelete(file)} />
|
||||
)}
|
||||
{!quickSwitchMode && <FileMenuButton onDelete={() => onDelete(file)} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
|
||||
function BudgetFiles({
|
||||
files,
|
||||
quickSwitchMode,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
files: File[];
|
||||
quickSwitchMode: boolean;
|
||||
onSelect: (file: File) => void;
|
||||
onDelete: (file: File) => void;
|
||||
}) {
|
||||
function isLocalFile(file: File): file is LocalFile {
|
||||
return file.state === 'local';
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -252,8 +283,8 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
|
||||
</Text>
|
||||
) : (
|
||||
files.map(file => (
|
||||
<File
|
||||
key={file.id || file.cloudFileId}
|
||||
<FileItem
|
||||
key={isLocalFile(file) ? file.id : file.cloudFileId}
|
||||
file={file}
|
||||
quickSwitchMode={quickSwitchMode}
|
||||
onSelect={onSelect}
|
||||
@@ -265,7 +296,13 @@ function BudgetFiles({ files, quickSwitchMode, onSelect, onDelete }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshButton({ style, onRefresh }) {
|
||||
function RefreshButton({
|
||||
style,
|
||||
onRefresh,
|
||||
}: {
|
||||
style?: CSSProperties;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function _onRefresh() {
|
||||
@@ -288,7 +325,13 @@ function RefreshButton({ style, onRefresh }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetListHeader({ quickSwitchMode, onRefresh }) {
|
||||
function BudgetListHeader({
|
||||
quickSwitchMode,
|
||||
onRefresh,
|
||||
}: {
|
||||
quickSwitchMode: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -314,7 +357,14 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
||||
const allFiles = useSelector(state => state.budgets.allFiles || []);
|
||||
const [id] = useLocalPref('id');
|
||||
|
||||
const files = id ? allFiles.filter(f => f.id !== id) : allFiles;
|
||||
// Remote files do not have the 'id' field
|
||||
function isNonRemoteFile(
|
||||
file: File,
|
||||
): file is LocalFile | SyncableLocalFile | SyncedLocalFile {
|
||||
return file.state !== 'remote';
|
||||
}
|
||||
const nonRemoteFiles = allFiles.filter(isNonRemoteFile);
|
||||
const files = id ? nonRemoteFiles.filter(f => f.id !== id) : allFiles;
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
@@ -324,7 +374,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
||||
}
|
||||
: {};
|
||||
|
||||
const onCreate = ({ testMode } = {}) => {
|
||||
const onCreate = ({ testMode = false } = {}) => {
|
||||
if (!creating) {
|
||||
setCreating(true);
|
||||
dispatch(createBudget({ testMode }));
|
||||
@@ -341,6 +391,22 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
const onSelect = (file: File): void => {
|
||||
const isRemoteFile = file.state === 'remote';
|
||||
|
||||
if (!id) {
|
||||
if (isRemoteFile) {
|
||||
dispatch(downloadBudget(file.cloudFileId));
|
||||
} else {
|
||||
dispatch(loadBudget(file.id));
|
||||
}
|
||||
} else if (!isRemoteFile && file.id !== id) {
|
||||
dispatch(closeAndLoadBudget(file.id));
|
||||
} else if (isRemoteFile) {
|
||||
dispatch(closeAndDownloadBudget(file.cloudFileId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -365,21 +431,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
||||
<BudgetFiles
|
||||
files={files}
|
||||
quickSwitchMode={quickSwitchMode}
|
||||
onSelect={file => {
|
||||
if (!id) {
|
||||
if (file.state === 'remote') {
|
||||
dispatch(downloadBudget(file.cloudFileId));
|
||||
} else {
|
||||
dispatch(loadBudget(file.id));
|
||||
}
|
||||
} else if (file.id !== id) {
|
||||
if (file.state === 'remote') {
|
||||
dispatch(closeAndDownloadBudget(file.cloudFileId));
|
||||
} else {
|
||||
dispatch(closeAndLoadBudget(file.id));
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
onDelete={file => dispatch(pushModal('delete-budget', { file }))}
|
||||
/>
|
||||
{!quickSwitchMode && (
|
||||
@@ -408,7 +460,7 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onCreate}
|
||||
onClick={() => onCreate()}
|
||||
style={{
|
||||
...narrowButtonStyle,
|
||||
marginLeft: 10,
|
||||
@@ -7,7 +7,6 @@ import React, {
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
|
||||
import {
|
||||
@@ -20,7 +19,10 @@ import {
|
||||
syncAndDownload,
|
||||
updateAccount,
|
||||
} from 'loot-core/client/actions';
|
||||
import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules';
|
||||
import {
|
||||
SchedulesProvider,
|
||||
useDefaultSchedulesQueryTransform,
|
||||
} from 'loot-core/client/data-hooks/schedules';
|
||||
import * as queries from 'loot-core/client/queries';
|
||||
import { pagedQuery } from 'loot-core/client/query-helpers';
|
||||
import { listen, send } from 'loot-core/platform/client/fetch';
|
||||
@@ -39,6 +41,7 @@ import { AddTransactionButton } from '../transactions/AddTransactionButton';
|
||||
import { TransactionListWithBalances } from '../transactions/TransactionListWithBalances';
|
||||
|
||||
export function AccountTransactions({ account, pending, failed }) {
|
||||
const schedulesTransform = useDefaultSchedulesQueryTransform(account.id);
|
||||
return (
|
||||
<Page
|
||||
header={
|
||||
@@ -52,7 +55,7 @@ export function AccountTransactions({ account, pending, failed }) {
|
||||
}
|
||||
padding={0}
|
||||
>
|
||||
<SchedulesProvider transform={getSchedulesTransform(account.id)}>
|
||||
<SchedulesProvider transform={schedulesTransform}>
|
||||
<TransactionListWithPreviews account={account} />
|
||||
</SchedulesProvider>
|
||||
</Page>
|
||||
@@ -132,15 +135,6 @@ function AccountName({ account, pending, failed }) {
|
||||
);
|
||||
}
|
||||
|
||||
const getSchedulesTransform = memoizeOne(id => {
|
||||
const filter = queries.getAccountFilter(id, '_account');
|
||||
|
||||
return q => {
|
||||
q = q.filter({ $and: [filter, { '_account.closed': false }] });
|
||||
return q.orderBy({ next_date: 'desc' });
|
||||
};
|
||||
});
|
||||
|
||||
function TransactionListWithPreviews({ account }) {
|
||||
const [currentQuery, setCurrentQuery] = useState();
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
@@ -378,7 +378,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
const onCover = () => {
|
||||
dispatch(
|
||||
pushModal('cover', {
|
||||
categoryId: category.id,
|
||||
title: category.name,
|
||||
month,
|
||||
onSubmit: fromCategoryId => {
|
||||
onBudgetAction(month, 'cover-overspending', {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
updateCategory,
|
||||
updateGroup,
|
||||
sync,
|
||||
loadPrefs,
|
||||
} from 'loot-core/client/actions';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
@@ -31,7 +30,7 @@ import { useLocalPref } from '../../../hooks/useLocalPref';
|
||||
import { useSetThemeColor } from '../../../hooks/useSetThemeColor';
|
||||
import { AnimatedLoading } from '../../../icons/AnimatedLoading';
|
||||
import { theme } from '../../../style';
|
||||
import { prewarmMonth, switchBudgetType } from '../../budget/util';
|
||||
import { prewarmMonth } from '../../budget/util';
|
||||
import { View } from '../../common/View';
|
||||
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||
import { SyncRefresh } from '../../SyncRefresh';
|
||||
@@ -289,23 +288,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
// );
|
||||
// };
|
||||
|
||||
const onSwitchBudgetType = async () => {
|
||||
setInitialized(false);
|
||||
|
||||
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
|
||||
await switchBudgetType(
|
||||
newBudgetType,
|
||||
spreadsheet,
|
||||
bounds,
|
||||
startMonth,
|
||||
async () => {
|
||||
dispatch(loadPrefs());
|
||||
},
|
||||
);
|
||||
|
||||
setInitialized(true);
|
||||
};
|
||||
|
||||
const onSaveNotes = async (id, notes) => {
|
||||
await send('notes-save', { id, note: notes });
|
||||
};
|
||||
@@ -358,17 +340,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const onOpenSwitchBudgetTypeModal = () => {
|
||||
dispatch(
|
||||
pushModal('switch-budget-type', {
|
||||
onSwitch: () => {
|
||||
onSwitchBudgetType();
|
||||
dispatch(collapseModals('budget-page-menu'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const [showHiddenCategories, setShowHiddenCategoriesPref] = useLocalPref(
|
||||
'budget.showHiddenCategories',
|
||||
);
|
||||
@@ -408,7 +379,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
onAddCategoryGroup: onOpenNewCategoryGroupModal,
|
||||
onToggleHiddenCategories,
|
||||
onSwitchBudgetFile,
|
||||
onSwitchBudgetType: onOpenSwitchBudgetTypeModal,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -523,16 +523,20 @@ const TransactionEditInner = memo(function TransactionEditInner({
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
|
||||
const onConfirmSave = async () => {
|
||||
const { account: accountId } = unserializedTransaction;
|
||||
const account = accountsById[accountId];
|
||||
|
||||
let transactionsToSave = unserializedTransactions;
|
||||
if (adding) {
|
||||
transactionsToSave = realizeTempTransactions(unserializedTransactions);
|
||||
}
|
||||
|
||||
props.onSave(transactionsToSave);
|
||||
navigate(`/accounts/${account.id}`, { replace: true });
|
||||
|
||||
if (adding) {
|
||||
const { account: accountId } = unserializedTransaction;
|
||||
const account = accountsById[accountId];
|
||||
navigate(`/accounts/${account.id}`, { replace: true });
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
if (unserializedTransaction.reconciled) {
|
||||
@@ -639,12 +643,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
|
||||
return;
|
||||
}
|
||||
|
||||
const { account: accountId } = unserializedTransaction;
|
||||
if (accountId) {
|
||||
navigate(`/accounts/${accountId}`, { replace: true });
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
navigate(-1);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Menu } from '../common/Menu';
|
||||
@@ -18,7 +17,6 @@ export function BudgetPageMenuModal({
|
||||
onAddCategoryGroup,
|
||||
onToggleHiddenCategories,
|
||||
onSwitchBudgetFile,
|
||||
onSwitchBudgetType,
|
||||
}: BudgetPageMenuModalProps) {
|
||||
const defaultMenuItemStyle: CSSProperties = {
|
||||
...styles.mobileMenuItem,
|
||||
@@ -34,7 +32,6 @@ export function BudgetPageMenuModal({
|
||||
onAddCategoryGroup={onAddCategoryGroup}
|
||||
onToggleHiddenCategories={onToggleHiddenCategories}
|
||||
onSwitchBudgetFile={onSwitchBudgetFile}
|
||||
onSwitchBudgetType={onSwitchBudgetType}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -47,17 +44,14 @@ type BudgetPageMenuProps = Omit<
|
||||
onAddCategoryGroup: () => void;
|
||||
onToggleHiddenCategories: () => void;
|
||||
onSwitchBudgetFile: () => void;
|
||||
onSwitchBudgetType: () => void;
|
||||
};
|
||||
|
||||
function BudgetPageMenu({
|
||||
onAddCategoryGroup,
|
||||
onToggleHiddenCategories,
|
||||
onSwitchBudgetFile,
|
||||
onSwitchBudgetType,
|
||||
...props
|
||||
}: BudgetPageMenuProps) {
|
||||
const isReportBudgetEnabled = useFeatureFlag('reportBudget');
|
||||
const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
|
||||
|
||||
const onMenuSelect = (name: string) => {
|
||||
@@ -74,9 +68,6 @@ function BudgetPageMenu({
|
||||
case 'switch-budget-file':
|
||||
onSwitchBudgetFile?.();
|
||||
break;
|
||||
case 'switch-budget-type':
|
||||
onSwitchBudgetType?.();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu item: ${name}`);
|
||||
}
|
||||
@@ -99,14 +90,6 @@ function BudgetPageMenu({
|
||||
name: 'switch-budget-file',
|
||||
text: 'Switch budget file',
|
||||
},
|
||||
...(isReportBudgetEnabled
|
||||
? [
|
||||
{
|
||||
name: 'switch-budget-type',
|
||||
text: 'Switch budget type',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
@@ -16,10 +16,10 @@ import { Link } from '../common/Link';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Modal } from '../common/Modal';
|
||||
import { Paragraph } from '../common/Paragraph';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { type CommonModalProps } from '../Modals';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
type CreateAccountProps = {
|
||||
modalProps: CommonModalProps;
|
||||
@@ -38,6 +38,7 @@ export function CreateAccountModal({
|
||||
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] =
|
||||
useState(null);
|
||||
const [menuGoCardlessOpen, setGoCardlessMenuOpen] = useState<boolean>(false);
|
||||
const triggerRef = useRef(null);
|
||||
const [menuSimplefinOpen, setSimplefinMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const onConnectGoCardless = () => {
|
||||
@@ -235,39 +236,40 @@ export function CreateAccountModal({
|
||||
: 'Set up GoCardless for bank sync'}
|
||||
</ButtonWithLoading>
|
||||
{isGoCardlessSetupComplete && (
|
||||
<Button
|
||||
type="bare"
|
||||
onClick={() => setGoCardlessMenuOpen(true)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
{menuGoCardlessOpen && (
|
||||
<Tooltip
|
||||
position="bottom-right"
|
||||
width={200}
|
||||
style={{ padding: 0 }}
|
||||
onClose={() => setGoCardlessMenuOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onGoCardlessReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: 'Reset GoCardless credentials',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
onClick={() => setGoCardlessMenuOpen(true)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={menuGoCardlessOpen}
|
||||
onOpenChange={() => setGoCardlessMenuOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onGoCardlessReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: 'Reset GoCardless credentials',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
@@ -303,39 +305,39 @@ export function CreateAccountModal({
|
||||
: 'Set up SimpleFIN for bank sync'}
|
||||
</ButtonWithLoading>
|
||||
{isSimpleFinSetupComplete && (
|
||||
<Button
|
||||
type="bare"
|
||||
onClick={() => setSimplefinMenuOpen(true)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
{menuSimplefinOpen && (
|
||||
<Tooltip
|
||||
position="bottom-right"
|
||||
width={200}
|
||||
style={{ padding: 0 }}
|
||||
onClose={() => setSimplefinMenuOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onSimpleFinReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: 'Reset SimpleFIN credentials',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
onClick={() => setSimplefinMenuOpen(true)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={menuSimplefinOpen}
|
||||
onOpenChange={() => setSimplefinMenuOpen(false)}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onSimpleFinReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: 'Reset SimpleFIN credentials',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
|
||||
@@ -122,7 +122,7 @@ export function OpSelect({
|
||||
.map(op => [op, formatOp(op, type)]);
|
||||
|
||||
if (type === 'string' || type === 'id') {
|
||||
options.splice(options.length / 2, 0, Menu.line);
|
||||
options.splice(Math.ceil(options.length / 2), 0, Menu.line);
|
||||
}
|
||||
|
||||
return options;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
import * as d from 'date-fns';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useLocalPrefs } from '../../hooks/useLocalPrefs';
|
||||
import { SvgDownAndRightArrow } from '../../icons/v2';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button, ButtonWithLoading } from '../common/Button';
|
||||
import { Input } from '../common/Input';
|
||||
@@ -19,6 +20,7 @@ import { Modal } from '../common/Modal';
|
||||
import { Select } from '../common/Select';
|
||||
import { Stack } from '../common/Stack';
|
||||
import { Text } from '../common/Text';
|
||||
import { Tooltip } from '../common/Tooltip';
|
||||
import { View } from '../common/View';
|
||||
import { Checkbox, SectionLabel } from '../forms';
|
||||
import { TableHeader, TableWithNavigator, Row, Field } from '../table';
|
||||
@@ -245,6 +247,12 @@ function applyFieldMappings(transaction, mappings) {
|
||||
|
||||
result[field] = transaction[target || field];
|
||||
}
|
||||
// Keep preview fields on the mapped transactions
|
||||
result.trx_id = transaction.trx_id;
|
||||
result.existing = transaction.existing;
|
||||
result.ignored = transaction.ignored;
|
||||
result.selected = transaction.selected;
|
||||
result.selected_merge = transaction.selected_merge;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -335,33 +343,124 @@ function Transaction({
|
||||
flipAmount,
|
||||
multiplierAmount,
|
||||
categories,
|
||||
onCheckTransaction,
|
||||
reconcile,
|
||||
}) {
|
||||
const categoryList = categories.map(category => category.name);
|
||||
const transaction = useMemo(
|
||||
() =>
|
||||
fieldMappings
|
||||
fieldMappings && !rawTransaction.isMatchedTransaction
|
||||
? applyFieldMappings(rawTransaction, fieldMappings)
|
||||
: rawTransaction,
|
||||
[rawTransaction, fieldMappings],
|
||||
);
|
||||
|
||||
const { amount, outflow, inflow } = parseAmountFields(
|
||||
transaction,
|
||||
splitMode,
|
||||
inOutMode,
|
||||
outValue,
|
||||
flipAmount,
|
||||
multiplierAmount,
|
||||
);
|
||||
let amount, outflow, inflow;
|
||||
if (rawTransaction.isMatchedTransaction) {
|
||||
amount = rawTransaction.amount;
|
||||
if (splitMode) {
|
||||
outflow = amount < 0 ? -amount : 0;
|
||||
inflow = amount > 0 ? amount : 0;
|
||||
}
|
||||
} else {
|
||||
({ amount, outflow, inflow } = parseAmountFields(
|
||||
transaction,
|
||||
splitMode,
|
||||
inOutMode,
|
||||
outValue,
|
||||
flipAmount,
|
||||
multiplierAmount,
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
color:
|
||||
(transaction.isMatchedTransaction && !transaction.selected_merge) ||
|
||||
!transaction.selected
|
||||
? theme.tableTextInactive
|
||||
: theme.tableText,
|
||||
}}
|
||||
>
|
||||
{reconcile && (
|
||||
<Field width={31}>
|
||||
{!transaction.isMatchedTransaction && (
|
||||
<Tooltip
|
||||
content={
|
||||
!transaction.existing && !transaction.ignored
|
||||
? 'New transaction. You can import it, or skip it.'
|
||||
: transaction.ignored
|
||||
? 'Already imported transaction. You can skip it, or import it again.'
|
||||
: transaction.existing
|
||||
? 'Updated transaction. You can update it, import it again, or skip it.'
|
||||
: ''
|
||||
}
|
||||
placement="right top"
|
||||
>
|
||||
<Checkbox
|
||||
checked={transaction.selected}
|
||||
onChange={() => onCheckTransaction(transaction.trx_id)}
|
||||
style={
|
||||
transaction.selected_merge
|
||||
? {
|
||||
':checked': {
|
||||
'::after': {
|
||||
background:
|
||||
theme.checkboxBackgroundSelected +
|
||||
// update sign from packages/desktop-client/src/icons/v1/layer.svg
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path fill="white" d="M10 1l10 6-10 6L0 7l10-6zm6.67 10L20 13l-10 6-10-6 3.33-2L10 15l6.67-4z" /></svg>\') 9px 9px',
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
'&': {
|
||||
border:
|
||||
'1px solid ' + theme.buttonNormalDisabledBorder,
|
||||
backgroundColor: theme.buttonNormalDisabledBorder,
|
||||
'::after': {
|
||||
display: 'block',
|
||||
background:
|
||||
theme.buttonNormalDisabledBorder +
|
||||
// minus sign adapted from packages/desktop-client/src/icons/v1/add.svg
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /></svg>\') 9px 9px',
|
||||
width: 9,
|
||||
height: 9,
|
||||
content: ' ',
|
||||
},
|
||||
},
|
||||
':checked': {
|
||||
border: '1px solid ' + theme.checkboxBorderSelected,
|
||||
backgroundColor: theme.checkboxBackgroundSelected,
|
||||
'::after': {
|
||||
background:
|
||||
theme.checkboxBackgroundSelected +
|
||||
// plus sign from packages/desktop-client/src/icons/v1/add.svg
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
' url(\'data:image/svg+xml; utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" className="path" d="M23,11.5 L23,11.5 L23,11.5 C23,12.3284271 22.3284271,13 21.5,13 L1.5,13 L1.5,13 C0.671572875,13 1.01453063e-16,12.3284271 0,11.5 L0,11.5 L0,11.5 C-1.01453063e-16,10.6715729 0.671572875,10 1.5,10 L21.5,10 L21.5,10 C22.3284271,10 23,10.6715729 23,11.5 Z" /><path fill="white" className="path" d="M11.5,23 C10.6715729,23 10,22.3284271 10,21.5 L10,1.5 C10,0.671572875 10.6715729,1.52179594e-16 11.5,0 C12.3284271,-1.52179594e-16 13,0.671572875 13,1.5 L13,21.5 C13,22.3284271 12.3284271,23 11.5,23 Z" /></svg>\') 9px 9px',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field width={200}>
|
||||
{showParsed ? (
|
||||
{transaction.isMatchedTransaction ? (
|
||||
<View>
|
||||
<Stack direction="row" align="flex-start">
|
||||
<View>
|
||||
<SvgDownAndRightArrow width={16} height={16} />
|
||||
</View>
|
||||
<View>{formatDate(transaction.date, dateFormat)}</View>
|
||||
</Stack>
|
||||
</View>
|
||||
) : showParsed ? (
|
||||
<ParsedDate
|
||||
parseDateFormat={parseDateFormat}
|
||||
dateFormat={dateFormat}
|
||||
@@ -625,7 +724,9 @@ function FieldMappings({
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = Object.keys(transactions[0]);
|
||||
const { existing, ignored, selected, selected_merge, trx_id, ...trans } =
|
||||
transactions[0];
|
||||
const options = Object.keys(trans);
|
||||
mappings = mappings || {};
|
||||
|
||||
return (
|
||||
@@ -738,8 +839,13 @@ function FieldMappings({
|
||||
export function ImportTransactions({ modalProps, options }) {
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const prefs = useLocalPrefs();
|
||||
const { parseTransactions, importTransactions, getPayees, savePrefs } =
|
||||
useActions();
|
||||
const {
|
||||
parseTransactions,
|
||||
importTransactions,
|
||||
importPreviewTransactions,
|
||||
getPayees,
|
||||
savePrefs,
|
||||
} = useActions();
|
||||
|
||||
const [multiplierAmount, setMultiplierAmount] = useState('');
|
||||
const [loadingState, setLoadingState] = useState('parsing');
|
||||
@@ -754,6 +860,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
const [flipAmount, setFlipAmount] = useState(false);
|
||||
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
|
||||
const [reconcile, setReconcile] = useState(true);
|
||||
const [previewTrigger, setPreviewTrigger] = useState(0);
|
||||
const { accountId, categories, onImported } = options;
|
||||
|
||||
// This cannot be set after parsing the file, because changing it
|
||||
@@ -783,7 +890,18 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
setFilename(filename);
|
||||
setFileType(filetype);
|
||||
|
||||
const { errors, transactions } = await parseTransactions(filename, options);
|
||||
const { errors, transactions: parsedTransactions } =
|
||||
await parseTransactions(filename, options);
|
||||
|
||||
let index = 0;
|
||||
const transactions = parsedTransactions.map(trans => {
|
||||
// Add a transient transaction id to match preview with imported transactions
|
||||
trans.trx_id = index++;
|
||||
// Select all parsed transactions before first preview run
|
||||
trans.selected = true;
|
||||
return trans;
|
||||
});
|
||||
|
||||
setLoadingState(null);
|
||||
setError(null);
|
||||
|
||||
@@ -794,8 +912,14 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
message: errors[0].message || 'Internal error',
|
||||
});
|
||||
} else {
|
||||
let flipAmount = false;
|
||||
let fieldMappings = null;
|
||||
let splitMode = false;
|
||||
let parseDateFormat = null;
|
||||
|
||||
if (filetype === 'csv' || filetype === 'qif') {
|
||||
setFlipAmount(prefs[`flip-amount-${accountId}-${filetype}`] || false);
|
||||
flipAmount = prefs[`flip-amount-${accountId}-${filetype}`] || false;
|
||||
setFlipAmount(flipAmount);
|
||||
}
|
||||
|
||||
if (filetype === 'csv') {
|
||||
@@ -804,21 +928,22 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
? JSON.parse(mappings)
|
||||
: getInitialMappings(transactions);
|
||||
|
||||
fieldMappings = mappings;
|
||||
setFieldMappings(mappings);
|
||||
|
||||
// Set initial split mode based on any saved mapping
|
||||
const initialSplitMode = !!(mappings.outflow || mappings.inflow);
|
||||
setSplitMode(initialSplitMode);
|
||||
splitMode = !!(mappings.outflow || mappings.inflow);
|
||||
setSplitMode(splitMode);
|
||||
|
||||
setParseDateFormat(
|
||||
parseDateFormat =
|
||||
prefs[`parse-date-${accountId}-${filetype}`] ||
|
||||
getInitialDateFormat(transactions, mappings),
|
||||
);
|
||||
getInitialDateFormat(transactions, mappings);
|
||||
setParseDateFormat(parseDateFormat);
|
||||
} else if (filetype === 'qif') {
|
||||
setParseDateFormat(
|
||||
parseDateFormat =
|
||||
prefs[`parse-date-${accountId}-${filetype}`] ||
|
||||
getInitialDateFormat(transactions, { date: 'date' }),
|
||||
);
|
||||
getInitialDateFormat(transactions, { date: 'date' });
|
||||
setParseDateFormat(parseDateFormat);
|
||||
} else {
|
||||
setFieldMappings(null);
|
||||
setParseDateFormat(null);
|
||||
@@ -827,7 +952,18 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
// Reverse the transactions because it's very common for them to
|
||||
// be ordered ascending, but we show transactions descending by
|
||||
// date. This is purely cosmetic.
|
||||
setTransactions(transactions.reverse());
|
||||
const transactionPreview = await getImportPreview(
|
||||
transactions.reverse(),
|
||||
filetype,
|
||||
flipAmount,
|
||||
fieldMappings,
|
||||
splitMode,
|
||||
parseDateFormat,
|
||||
inOutMode,
|
||||
outValue,
|
||||
multiplierAmount,
|
||||
);
|
||||
setTransactions(transactionPreview);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,6 +971,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
const amt = e;
|
||||
if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) {
|
||||
setMultiplierAmount(amt);
|
||||
runImportPreview();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,7 +1039,54 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
}
|
||||
|
||||
function onUpdateFields(field, name) {
|
||||
setFieldMappings({ ...fieldMappings, [field]: name === '' ? null : name });
|
||||
const newFieldMappings = {
|
||||
...fieldMappings,
|
||||
[field]: name === '' ? null : name,
|
||||
};
|
||||
setFieldMappings(newFieldMappings);
|
||||
runImportPreview();
|
||||
}
|
||||
|
||||
function onCheckTransaction(trx_id) {
|
||||
const newTransactions = transactions.map(trans => {
|
||||
if (trans.trx_id === trx_id) {
|
||||
if (trans.existing) {
|
||||
// 3-states management for transactions with existing (merged transactions)
|
||||
// flow of states:
|
||||
// (selected true && selected_merge true)
|
||||
// => (selected true && selected_merge false)
|
||||
// => (selected false)
|
||||
// => back to (selected true && selected_merge true)
|
||||
if (!trans.selected) {
|
||||
return {
|
||||
...trans,
|
||||
selected: true,
|
||||
selected_merge: true,
|
||||
};
|
||||
} else if (trans.selected_merge) {
|
||||
return {
|
||||
...trans,
|
||||
selected: true,
|
||||
selected_merge: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...trans,
|
||||
selected: false,
|
||||
selected_merge: false,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
...trans,
|
||||
selected: !trans.selected,
|
||||
};
|
||||
}
|
||||
}
|
||||
return trans;
|
||||
});
|
||||
|
||||
setTransactions(newTransactions);
|
||||
}
|
||||
|
||||
async function onImport() {
|
||||
@@ -912,6 +1096,16 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
let errorMessage;
|
||||
|
||||
for (let trans of transactions) {
|
||||
if (
|
||||
trans.isMatchedTransaction ||
|
||||
(reconcile && !trans.selected && !trans.ignored)
|
||||
) {
|
||||
// skip transactions that are
|
||||
// - matched transaction (existing transaction added to show update changes)
|
||||
// - unselected transactions that are not ignored by the reconcilation algorithm (only when reconcilation is enabled)
|
||||
continue;
|
||||
}
|
||||
|
||||
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
|
||||
|
||||
const date =
|
||||
@@ -941,7 +1135,29 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
const category_id = parseCategoryFields(trans, categories.list);
|
||||
trans.category = category_id;
|
||||
|
||||
const { inflow, outflow, inOut, ...finalTransaction } = trans;
|
||||
const {
|
||||
inflow,
|
||||
outflow,
|
||||
inOut,
|
||||
existing,
|
||||
ignored,
|
||||
selected,
|
||||
selected_merge,
|
||||
trx_id,
|
||||
...finalTransaction
|
||||
} = trans;
|
||||
|
||||
if (
|
||||
reconcile &&
|
||||
((trans.ignored && trans.selected) ||
|
||||
(trans.existing && trans.selected && !trans.selected_merge))
|
||||
) {
|
||||
// in reconcile mode, force transaction add for
|
||||
// - ignored transactions (aleardy existing) that are checked
|
||||
// - transactions with existing (merged transactions) that are not selected_merge
|
||||
finalTransaction.forceAddTransaction = true;
|
||||
}
|
||||
|
||||
finalTransactions.push({
|
||||
...finalTransaction,
|
||||
date,
|
||||
@@ -994,6 +1210,156 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
modalProps.onClose();
|
||||
}
|
||||
|
||||
const runImportPreviewCallback = useCallback(async () => {
|
||||
const transactionPreview = await getImportPreview(
|
||||
transactions,
|
||||
filetype,
|
||||
flipAmount,
|
||||
fieldMappings,
|
||||
splitMode,
|
||||
parseDateFormat,
|
||||
inOutMode,
|
||||
outValue,
|
||||
multiplierAmount,
|
||||
);
|
||||
setTransactions(transactionPreview);
|
||||
}, [
|
||||
transactions,
|
||||
filetype,
|
||||
flipAmount,
|
||||
fieldMappings,
|
||||
splitMode,
|
||||
parseDateFormat,
|
||||
inOutMode,
|
||||
outValue,
|
||||
multiplierAmount,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
runImportPreviewCallback();
|
||||
}, [previewTrigger]);
|
||||
|
||||
function runImportPreview() {
|
||||
setPreviewTrigger(value => value + 1);
|
||||
}
|
||||
|
||||
async function getImportPreview(
|
||||
transactions,
|
||||
filetype,
|
||||
flipAmount,
|
||||
fieldMappings,
|
||||
splitMode,
|
||||
parseDateFormat,
|
||||
inOutMode,
|
||||
outValue,
|
||||
multiplierAmount,
|
||||
) {
|
||||
const previewTransactions = [];
|
||||
|
||||
for (let trans of transactions) {
|
||||
if (trans.isMatchedTransaction) {
|
||||
// skip transactions that are matched transaction (existing transaction added to show update changes)
|
||||
continue;
|
||||
}
|
||||
|
||||
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
|
||||
|
||||
const date = isOfxFile(filetype)
|
||||
? trans.date
|
||||
: parseDate(trans.date, parseDateFormat);
|
||||
if (date == null) {
|
||||
console.log(
|
||||
`Unable to parse date ${
|
||||
trans.date || '(empty)'
|
||||
} with given date format`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { amount } = parseAmountFields(
|
||||
trans,
|
||||
splitMode,
|
||||
inOutMode,
|
||||
outValue,
|
||||
flipAmount,
|
||||
multiplierAmount,
|
||||
);
|
||||
if (amount == null) {
|
||||
console.log(`Transaction on ${trans.date} has no amount`);
|
||||
break;
|
||||
}
|
||||
|
||||
const category_id = parseCategoryFields(trans, categories.list);
|
||||
if (category_id != null) {
|
||||
trans.category = category_id;
|
||||
}
|
||||
|
||||
const {
|
||||
inflow,
|
||||
outflow,
|
||||
inOut,
|
||||
existing,
|
||||
ignored,
|
||||
selected,
|
||||
selected_merge,
|
||||
...finalTransaction
|
||||
} = trans;
|
||||
previewTransactions.push({
|
||||
...finalTransaction,
|
||||
date,
|
||||
amount: amountToInteger(amount),
|
||||
cleared: clearOnImport,
|
||||
});
|
||||
}
|
||||
|
||||
// Retreive the transactions that would be updated (along with the existing trx)
|
||||
const previewTrx = await importPreviewTransactions(
|
||||
accountId,
|
||||
previewTransactions,
|
||||
);
|
||||
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
|
||||
map[entry.transaction.trx_id] = entry;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return transactions
|
||||
.filter(trans => !trans.isMatchedTransaction)
|
||||
.reduce((previous, current_trx) => {
|
||||
let next = previous;
|
||||
const entry = matchedUpdateMap[current_trx.trx_id];
|
||||
const existing_trx = entry?.existing;
|
||||
|
||||
// if the transaction is matched with an existing one for update
|
||||
current_trx.existing = !!existing_trx;
|
||||
// if the transaction is an update that will be ignored
|
||||
// (reconciled transactions or no change detected)
|
||||
current_trx.ignored = entry?.ignored || false;
|
||||
|
||||
current_trx.selected = !current_trx.ignored;
|
||||
current_trx.selected_merge = current_trx.existing;
|
||||
|
||||
next = next.concat({ ...current_trx });
|
||||
|
||||
if (existing_trx) {
|
||||
// add the updated existing transaction in the list, with the
|
||||
// isMatchedTransaction flag to identify it in display and not send it again
|
||||
existing_trx.isMatchedTransaction = true;
|
||||
existing_trx.category = categories.list.find(
|
||||
cat => cat.id === existing_trx.category,
|
||||
)?.name;
|
||||
// add parent transaction attribute to mimic behaviour
|
||||
existing_trx.trx_id = current_trx.trx_id;
|
||||
existing_trx.existing = current_trx.existing;
|
||||
existing_trx.selected = current_trx.selected;
|
||||
existing_trx.selected_merge = current_trx.selected_merge;
|
||||
|
||||
next = next.concat({ ...existing_trx });
|
||||
}
|
||||
|
||||
return next;
|
||||
}, []);
|
||||
}
|
||||
|
||||
const headers = [
|
||||
{ name: 'Date', width: 200 },
|
||||
{ name: 'Payee', width: 'flex' },
|
||||
@@ -1001,6 +1367,9 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
{ name: 'Category', width: 'flex' },
|
||||
];
|
||||
|
||||
if (reconcile) {
|
||||
headers.unshift({ name: ' ', width: 31 });
|
||||
}
|
||||
if (inOutMode) {
|
||||
headers.push({ name: 'In/Out', width: 90, style: { textAlign: 'left' } });
|
||||
}
|
||||
@@ -1038,7 +1407,11 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
<TableHeader headers={headers} />
|
||||
|
||||
<TableWithNavigator
|
||||
items={transactions}
|
||||
items={transactions.filter(
|
||||
trans =>
|
||||
!trans.isMatchedTransaction ||
|
||||
(trans.isMatchedTransaction && reconcile),
|
||||
)}
|
||||
fields={['payee', 'category', 'amount']}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
getItemKey={index => index}
|
||||
@@ -1070,6 +1443,8 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
flipAmount={flipAmount}
|
||||
multiplierAmount={multiplierAmount}
|
||||
categories={categories.list}
|
||||
onCheckTransaction={onCheckTransaction}
|
||||
reconcile={reconcile}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -1094,7 +1469,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
)}
|
||||
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginTop: 25 }}>
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<FieldMappings
|
||||
transactions={transactions}
|
||||
onChange={onUpdateFields}
|
||||
@@ -1128,16 +1503,16 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(state => !state);
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
>
|
||||
Reconcile transactions
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{/*Import Options */}
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<View style={{ marginTop: 25 }}>
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
align="flex-start"
|
||||
@@ -1151,14 +1526,17 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
transactions={transactions}
|
||||
fieldMappings={fieldMappings}
|
||||
parseDateFormat={parseDateFormat}
|
||||
onChange={setParseDateFormat}
|
||||
onChange={value => {
|
||||
setParseDateFormat(value);
|
||||
runImportPreview();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* CSV Options */}
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginLeft: 25, gap: 5 }}>
|
||||
<View style={{ marginLeft: 10, gap: 5 }}>
|
||||
<SectionLabel title="CSV OPTIONS" />
|
||||
<label
|
||||
style={{
|
||||
@@ -1219,23 +1597,26 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(state => !state);
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
>
|
||||
Reconcile transactions
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<View style={{ marginRight: 25, gap: 5 }}>
|
||||
<View style={{ marginRight: 10, gap: 5 }}>
|
||||
<SectionLabel title="AMOUNT OPTIONS" />
|
||||
<CheckboxOption
|
||||
id="form_flip"
|
||||
checked={flipAmount}
|
||||
disabled={splitMode || inOutMode}
|
||||
onChange={() => setFlipAmount(!flipAmount)}
|
||||
onChange={() => {
|
||||
setFlipAmount(!flipAmount);
|
||||
runImportPreview();
|
||||
}}
|
||||
>
|
||||
Flip amount
|
||||
</CheckboxOption>
|
||||
@@ -1245,7 +1626,10 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
id="form_split"
|
||||
checked={splitMode}
|
||||
disabled={inOutMode || flipAmount}
|
||||
onChange={onSplitMode}
|
||||
onChange={() => {
|
||||
onSplitMode();
|
||||
runImportPreview();
|
||||
}}
|
||||
>
|
||||
Split amount into separate inflow/outflow columns
|
||||
</CheckboxOption>
|
||||
@@ -1253,7 +1637,10 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
disabled={splitMode || flipAmount}
|
||||
onToggle={() => setInOutMode(!inOutMode)}
|
||||
onToggle={() => {
|
||||
setInOutMode(!inOutMode);
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeText={setOutValue}
|
||||
/>
|
||||
</>
|
||||
@@ -1264,6 +1651,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
onToggle={() => {
|
||||
setMultiplierEnabled(!multiplierEnabled);
|
||||
setMultiplierAmount('');
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeAmount={onMultiplierChange}
|
||||
/>
|
||||
@@ -1279,15 +1667,21 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '1em',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
type="primary"
|
||||
disabled={transactions.length === 0}
|
||||
disabled={
|
||||
transactions?.filter(trans => !trans.isMatchedTransaction)
|
||||
.length === 0
|
||||
}
|
||||
loading={loadingState === 'importing'}
|
||||
onClick={onImport}
|
||||
>
|
||||
Import {transactions.length} transactions
|
||||
Import{' '}
|
||||
{transactions?.filter(trans => !trans.isMatchedTransaction).length}{' '}
|
||||
transactions
|
||||
</ButtonWithLoading>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
|
||||
import { Modal, type ModalProps } from '../common/Modal';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
|
||||
type KeyboardShortcutsModalProps = {
|
||||
modalProps?: Partial<ModalProps>;
|
||||
};
|
||||
|
||||
type KeyIconProps = {
|
||||
shortcut: string;
|
||||
};
|
||||
|
||||
type GroupHeadingProps = {
|
||||
group: string;
|
||||
};
|
||||
|
||||
type ShortcutProps = {
|
||||
shortcut: string;
|
||||
description: string;
|
||||
meta?: string;
|
||||
shift?: boolean;
|
||||
};
|
||||
|
||||
function KeyIcon({ shortcut }: KeyIconProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: '#fff',
|
||||
color: '#000',
|
||||
border: '1px solid #000',
|
||||
borderRadius: 8,
|
||||
minWidth: 35,
|
||||
minHeight: 35,
|
||||
filter: 'drop-shadow(1px 1px)',
|
||||
padding: 5,
|
||||
}}
|
||||
>
|
||||
{shortcut}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeading({ group }: GroupHeadingProps) {
|
||||
return (
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
marginTop: 20,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{group}:
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut({ shortcut, description, meta, shift }: ShortcutProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
marginBottom: 10,
|
||||
marginLeft: 20,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
{meta && (
|
||||
<>
|
||||
<KeyIcon shortcut={meta} />
|
||||
<Text
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{shift && (
|
||||
<>
|
||||
<KeyIcon shortcut="Shift" />
|
||||
<Text
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
paddingLeft: 2,
|
||||
paddingRight: 2,
|
||||
}}
|
||||
>
|
||||
+
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<KeyIcon shortcut={shortcut} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxWidth: 300,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutModal({
|
||||
modalProps,
|
||||
}: KeyboardShortcutsModalProps) {
|
||||
const location = useLocation();
|
||||
const onAccounts = location.pathname.startsWith('/accounts');
|
||||
const ctrl = Platform.OS === 'mac' ? '⌘' : 'Ctrl';
|
||||
return (
|
||||
<Modal title="Keyboard Shortcuts" {...modalProps}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
<Shortcut
|
||||
shortcut="O"
|
||||
description="Close the current budget and open another"
|
||||
meta={ctrl}
|
||||
/>
|
||||
<Shortcut shortcut="?" description="Show this help dialog" />
|
||||
{onAccounts && (
|
||||
<>
|
||||
<Shortcut shortcut="Enter" description="Move down when editing" />
|
||||
<Shortcut shortcut="Tab" description="Move right when editing" />
|
||||
<GroupHeading group="Select a transaction, then" />
|
||||
<Shortcut
|
||||
shortcut="J"
|
||||
description="Move to the next transaction down"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="K"
|
||||
description="Move to the next transaction up"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="↑"
|
||||
description="Move to the next transaction down and scroll"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="↓"
|
||||
description="Move to the next transaction up and scroll"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="Space"
|
||||
description="Toggle selection of current transaction"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="Space"
|
||||
description="Toggle all transactions between current and most recently selected transaction"
|
||||
shift={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
}}
|
||||
>
|
||||
<Shortcut
|
||||
shortcut="Z"
|
||||
description="Undo the last change"
|
||||
meta={ctrl}
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="Z"
|
||||
description="Redo the last undone change"
|
||||
shift={true}
|
||||
meta={ctrl}
|
||||
/>
|
||||
{onAccounts && (
|
||||
<>
|
||||
<Shortcut
|
||||
shortcut="Enter"
|
||||
description="Move up when editing"
|
||||
shift={true}
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="Tab"
|
||||
description="Move left when editing"
|
||||
shift={true}
|
||||
/>
|
||||
<GroupHeading group="With transaction(s) selected" />
|
||||
<Shortcut
|
||||
shortcut="F"
|
||||
description="Filter to the selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="D"
|
||||
description="Delete selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="A"
|
||||
description="Set account for selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="P"
|
||||
description="Set payee for selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="N"
|
||||
description="Set notes for selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="C"
|
||||
description="Set category for selected transactions"
|
||||
/>
|
||||
<Shortcut
|
||||
shortcut="L"
|
||||
description="Toggle cleared for current transaction"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Link } from '../common/Link';
|
||||
import { Modal, ModalTitle } from '../common/Modal';
|
||||
import { Paragraph } from '../common/Paragraph';
|
||||
import { Text } from '../common/Text';
|
||||
import { type CommonModalProps } from '../Modals';
|
||||
|
||||
type SwitchBudgetTypeModalProps = {
|
||||
modalProps: CommonModalProps;
|
||||
onSwitch: () => void;
|
||||
};
|
||||
|
||||
export function SwitchBudgetTypeModal({
|
||||
modalProps,
|
||||
onSwitch,
|
||||
}: SwitchBudgetTypeModalProps) {
|
||||
const [budgetType] = useLocalPref('budgetType');
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
height: styles.mobileMinHeight,
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<Modal
|
||||
title={<ModalTitle title="Switch budget type?" shrinkOnOverflow />}
|
||||
{...modalProps}
|
||||
>
|
||||
<>
|
||||
<Paragraph>
|
||||
You are currently using a{' '}
|
||||
<Text style={{ fontWeight: 600 }}>
|
||||
{budgetType === 'report' ? 'Report budget' : 'Rollover budget'}.
|
||||
</Text>{' '}
|
||||
Switching will not lose any data and you can always switch back.
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{
|
||||
...narrowStyle,
|
||||
}}
|
||||
onClick={() => {
|
||||
onSwitch?.();
|
||||
modalProps.onClose?.();
|
||||
}}
|
||||
>
|
||||
Switch to a{' '}
|
||||
{budgetType === 'report' ? 'Rollover budget' : 'Report budget'}
|
||||
</Button>
|
||||
<Paragraph
|
||||
isLast={true}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/experimental/report-budget"
|
||||
linkColor="muted"
|
||||
>
|
||||
How do these types of budgeting work?
|
||||
</Link>
|
||||
</Paragraph>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { useReports } from 'loot-core/src/client/data-hooks/reports';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
@@ -56,16 +58,21 @@ export function Overview() {
|
||||
style={{ paddingBottom: MOBILE_NAV_HEIGHT }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: isNarrowWidth ? 'column' : 'row',
|
||||
className={`${css({
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
flexDirection: isNarrowWidth ? 'column' : 'row',
|
||||
flexWrap: isNarrowWidth ? 'nowrap' : 'wrap',
|
||||
padding: '10',
|
||||
'> a, > div': {
|
||||
margin: '10',
|
||||
},
|
||||
})}`}
|
||||
>
|
||||
<NetWorthCard accounts={accounts} />
|
||||
<CashFlowCard />
|
||||
{spendingReportFeatureFlag && <SpendingCard />}
|
||||
<CustomReportListCards reports={customReports} />
|
||||
</View>
|
||||
<CustomReportListCards reports={customReports} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react';
|
||||
|
||||
import { type CustomReportEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme } from '../../style';
|
||||
import { Link } from '../common/Link';
|
||||
import { View } from '../common/View';
|
||||
@@ -10,7 +11,7 @@ type ReportCardProps = {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
report?: CustomReportEntity;
|
||||
flex?: string;
|
||||
size?: number;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -18,10 +19,13 @@ export function ReportCard({
|
||||
to,
|
||||
report,
|
||||
children,
|
||||
flex,
|
||||
size = 1,
|
||||
style,
|
||||
}: ReportCardProps) {
|
||||
const containerProps = { flex, margin: 15 };
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const containerProps = {
|
||||
flex: isNarrowWidth ? '1 1' : `0 0 calc(${size * 100}% / 3 - 20px)`,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View
|
||||
|
||||
@@ -121,7 +121,12 @@ export function NetWorthGraph({
|
||||
width={width}
|
||||
height={height}
|
||||
data={graphData.data}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: compact ? 0 : computePadding(graphData.data),
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{compact ? null : (
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
@@ -180,3 +185,22 @@ export function NetWorthGraph({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add left padding for Y-axis for when large amounts get clipped
|
||||
* @param netWorthData
|
||||
* @returns left padding for Net worth graph
|
||||
*/
|
||||
function computePadding(netWorthData: Array<{ y: number }>) {
|
||||
/**
|
||||
* Convert to string notation, get longest string length
|
||||
*/
|
||||
const maxLength = Math.max(
|
||||
...netWorthData.map(({ y }) => {
|
||||
return Math.round(y).toLocaleString().length;
|
||||
}),
|
||||
);
|
||||
|
||||
// No additional left padding is required for upto 5 characters
|
||||
return Math.max(0, (maxLength - 5) * 5);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function CashFlowCard() {
|
||||
const income = graphData?.income || 0;
|
||||
|
||||
return (
|
||||
<ReportCard flex={1} to="/reports/cash-flow">
|
||||
<ReportCard to="/reports/cash-flow">
|
||||
<View
|
||||
style={{ flex: 1 }}
|
||||
onPointerEnter={onCardHover}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { send, sendCatch } from 'loot-core/platform/client/fetch/index';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
@@ -47,9 +47,9 @@ export function CustomReportListCards({
|
||||
const payees = usePayees();
|
||||
const accounts = useAccounts();
|
||||
const categories = useCategories();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const [_firstDayOfWeekIdx] = useLocalPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const [isCardHovered, setIsCardHovered] = useState('');
|
||||
|
||||
@@ -118,137 +118,90 @@ export function CustomReportListCards({
|
||||
setNameMenuOpen({ ...nameMenuOpen, [item]: state });
|
||||
};
|
||||
|
||||
const chunkSize = 3;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return reports
|
||||
.map((report: CustomReportEntity, i: number) => {
|
||||
return i % chunkSize === 0 ? reports.slice(i, i + chunkSize) : null;
|
||||
})
|
||||
.filter(e => {
|
||||
return e;
|
||||
});
|
||||
}, [reports]);
|
||||
|
||||
const remainder = 3 - (reports.length % 3);
|
||||
|
||||
if (reports.length === 0) return null;
|
||||
return (
|
||||
<View>
|
||||
{groups.map((group, i) => (
|
||||
<>
|
||||
{reports.map((report, id) => (
|
||||
<View
|
||||
key={i}
|
||||
key={id}
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
flexDirection: isNarrowWidth ? 'column' : 'row',
|
||||
flex: isNarrowWidth ? '1 1' : `0 0 calc(100% / 3 - 20px)`,
|
||||
}}
|
||||
>
|
||||
{group &&
|
||||
group.map((report, id) => (
|
||||
<ReportCard to="/reports/custom" report={report}>
|
||||
<View
|
||||
style={{ flex: 1, padding: 10 }}
|
||||
onMouseEnter={() =>
|
||||
setIsCardHovered(report.id === undefined ? '' : report.id)
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
setIsCardHovered('');
|
||||
onMenuOpen(report.id === undefined ? '' : report.id, false);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
key={id}
|
||||
style={
|
||||
!isNarrowWidth
|
||||
? {
|
||||
position: 'relative',
|
||||
flex: '1',
|
||||
}
|
||||
: {
|
||||
position: 'relative',
|
||||
}
|
||||
}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
paddingBottom: 5,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: '100%', height: '100%' }}>
|
||||
<ReportCard to="/reports/custom" report={report}>
|
||||
<View
|
||||
style={{ flex: 1, padding: 10 }}
|
||||
onMouseEnter={() =>
|
||||
setIsCardHovered(
|
||||
report.id === undefined ? '' : report.id,
|
||||
)
|
||||
}
|
||||
onMouseLeave={() => {
|
||||
setIsCardHovered('');
|
||||
onMenuOpen(
|
||||
report.id === undefined ? '' : report.id,
|
||||
false,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
paddingBottom: 5,
|
||||
}}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Block
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 500,
|
||||
marginBottom: 5,
|
||||
}}
|
||||
role="heading"
|
||||
>
|
||||
{report.name}
|
||||
</Block>
|
||||
{report.isDateStatic ? (
|
||||
<DateRange
|
||||
start={report.startDate}
|
||||
end={report.endDate}
|
||||
/>
|
||||
) : (
|
||||
<Text style={{ color: theme.pageTextSubdued }}>
|
||||
{report.dateRange}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<GetCardData
|
||||
report={report}
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
categories={categories}
|
||||
earliestTransaction={earliestTransaction}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
/>
|
||||
</View>
|
||||
</ReportCard>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
position: 'absolute',
|
||||
right: 25,
|
||||
top: 25,
|
||||
}}
|
||||
>
|
||||
<ListCardsPopover
|
||||
report={report}
|
||||
onMenuOpen={onMenuOpen}
|
||||
isCardHovered={isCardHovered}
|
||||
reportMenu={reportMenu}
|
||||
onMenuSelect={onMenuSelect}
|
||||
nameMenuOpen={nameMenuOpen}
|
||||
name={name}
|
||||
setName={setName}
|
||||
onAddUpdate={onAddUpdate}
|
||||
err={err}
|
||||
onNameMenuOpen={onNameMenuOpen}
|
||||
deleteMenuOpen={deleteMenuOpen}
|
||||
onDeleteMenuOpen={onDeleteMenuOpen}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Block
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 500,
|
||||
marginBottom: 5,
|
||||
}}
|
||||
role="heading"
|
||||
>
|
||||
{report.name}
|
||||
</Block>
|
||||
{report.isDateStatic ? (
|
||||
<DateRange start={report.startDate} end={report.endDate} />
|
||||
) : (
|
||||
<Text style={{ color: theme.pageTextSubdued }}>
|
||||
{report.dateRange}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{remainder !== 3 &&
|
||||
i + 1 === groups.length &&
|
||||
[...Array(remainder)].map((e, i) => (
|
||||
<View key={i} style={{ flex: 1 }} />
|
||||
))}
|
||||
<GetCardData
|
||||
report={report}
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
categories={categories}
|
||||
earliestTransaction={earliestTransaction}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
/>
|
||||
</View>
|
||||
</ReportCard>
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
position: 'absolute',
|
||||
right: 10,
|
||||
top: 10,
|
||||
}}
|
||||
>
|
||||
<ListCardsPopover
|
||||
report={report}
|
||||
onMenuOpen={onMenuOpen}
|
||||
isCardHovered={isCardHovered}
|
||||
reportMenu={reportMenu}
|
||||
onMenuSelect={onMenuSelect}
|
||||
nameMenuOpen={nameMenuOpen}
|
||||
name={name}
|
||||
setName={setName}
|
||||
onAddUpdate={onAddUpdate}
|
||||
err={err}
|
||||
onNameMenuOpen={onNameMenuOpen}
|
||||
deleteMenuOpen={deleteMenuOpen}
|
||||
onDeleteMenuOpen={onDeleteMenuOpen}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function NetWorthCard({ accounts }) {
|
||||
const data = useReport('net_worth', params);
|
||||
|
||||
return (
|
||||
<ReportCard flex={2} to="/reports/net-worth">
|
||||
<ReportCard size={2} to="/reports/net-worth">
|
||||
<View
|
||||
style={{ flex: 1 }}
|
||||
onPointerEnter={onCardHover}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function SpendingCard() {
|
||||
const showLastMonth = data && Math.abs(data.intervalData[27].lastMonth) > 0;
|
||||
|
||||
return (
|
||||
<ReportCard flex="1" to="/reports/spending">
|
||||
<ReportCard to="/reports/spending">
|
||||
<View
|
||||
style={{ flex: 1 }}
|
||||
onPointerEnter={() => setIsCardHovered(true)}
|
||||
|
||||
@@ -363,7 +363,6 @@ export function SchedulesTable({
|
||||
items={items as ScheduleEntity[]}
|
||||
renderItem={renderItem}
|
||||
renderEmpty={filter ? 'No matching schedules' : 'No schedules'}
|
||||
allowPopupsEscape={items.length < 6}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { loadPrefs } from 'loot-core/src/client/actions';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { switchBudgetType } from '../budget/util';
|
||||
import { ButtonWithLoading } from '../common/Button2';
|
||||
import { Link } from '../common/Link';
|
||||
import { Text } from '../common/Text';
|
||||
|
||||
import { Setting } from './UI';
|
||||
|
||||
export function BudgetTypeSettings() {
|
||||
const dispatch = useDispatch();
|
||||
const [budgetType] = useLocalPref('budgetType');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
const [startMonthPref] = useLocalPref('budget.startMonth');
|
||||
const startMonth = startMonthPref || currentMonth;
|
||||
const spreadsheet = useSpreadsheet();
|
||||
|
||||
function onSwitchType() {
|
||||
setLoading(true);
|
||||
|
||||
if (!loading) {
|
||||
const newBudgetType = budgetType === 'rollover' ? 'report' : 'rollover';
|
||||
|
||||
switchBudgetType(
|
||||
newBudgetType,
|
||||
spreadsheet,
|
||||
{
|
||||
start: startMonth,
|
||||
end: startMonth,
|
||||
},
|
||||
startMonth,
|
||||
async () => {
|
||||
dispatch(loadPrefs());
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Setting
|
||||
primaryAction={
|
||||
<ButtonWithLoading isLoading={loading} onPress={onSwitchType}>
|
||||
Switch to {budgetType === 'report' ? 'envelope' : 'tracking'}{' '}
|
||||
budgeting
|
||||
</ButtonWithLoading>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<strong>Envelope budgeting</strong> (recommended) digitally mimics
|
||||
physical envelope budgeting system by allocating funds into virtual
|
||||
envelopes for different expenses. It helps track spending and ensure you
|
||||
don‘t overspend in any category.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/envelope-budgeting"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more…
|
||||
</Link>
|
||||
</Text>
|
||||
<Text>
|
||||
With <strong>tracking budgeting</strong>, category balances reset each
|
||||
month, and funds are managed using a “Saved” metric instead of “To Be
|
||||
Budgeted.” Income is forecasted to plan future spending, rather than
|
||||
relying on current available funds.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/experimental/report-budget"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more…
|
||||
</Link>
|
||||
</Text>
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as Platform from 'loot-core/src/client/platform';
|
||||
import { listen } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useLatestVersion, useIsOutdated } from '../../hooks/useLatestVersion';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
@@ -23,6 +24,7 @@ import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs';
|
||||
import { Page } from '../Page';
|
||||
import { useServerVersion } from '../ServerContext';
|
||||
|
||||
import { BudgetTypeSettings } from './BudgetTypeSettings';
|
||||
import { EncryptionSettings } from './Encryption';
|
||||
import { ExperimentalFeatures } from './Experimental';
|
||||
import { ExportBudget } from './Export';
|
||||
@@ -178,6 +180,7 @@ export function Settings() {
|
||||
<ThemeSettings />
|
||||
<FormatSettings />
|
||||
<EncryptionSettings />
|
||||
{useFeatureFlag('reportBudget') && <BudgetTypeSettings />}
|
||||
<ExportBudget />
|
||||
|
||||
<AdvancedToggle>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { Resizable } from 're-resizable';
|
||||
|
||||
import {
|
||||
closeBudget,
|
||||
moveAccount,
|
||||
@@ -12,9 +14,11 @@ import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgReports, SvgWallet } from '../../icons/v1';
|
||||
import { SvgCalendar } from '../../icons/v2';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
@@ -30,20 +34,28 @@ import { useSidebar } from './SidebarProvider';
|
||||
import { ToggleButton } from './ToggleButton';
|
||||
import { Tools } from './Tools';
|
||||
|
||||
export const SIDEBAR_WIDTH = 240;
|
||||
|
||||
export function Sidebar() {
|
||||
const hasWindowButtons = !Platform.isBrowser && Platform.OS === 'mac';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const sidebar = useSidebar();
|
||||
const accounts = useAccounts();
|
||||
const { width } = useResponsive();
|
||||
const [showClosedAccounts, setShowClosedAccountsPref] = useLocalPref(
|
||||
'ui.showClosedAccounts',
|
||||
);
|
||||
const [isFloating = false, setFloatingSidebarPref] =
|
||||
useGlobalPref('floatingSidebar');
|
||||
|
||||
const [_sidebarWidth, setSidebarWidth] = useLocalPref('sidebarWidth');
|
||||
const DEFAULT_SIDEBAR_WIDTH = 240;
|
||||
const MAX_SIDEBAR_WIDTH = width / 3;
|
||||
const MIN_SIDEBAR_WIDTH = 200;
|
||||
const sidebarWidth = Math.min(
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
Math.max(MIN_SIDEBAR_WIDTH, _sidebarWidth || DEFAULT_SIDEBAR_WIDTH),
|
||||
);
|
||||
|
||||
async function onReorder(
|
||||
id: string,
|
||||
dropPos: 'top' | 'bottom',
|
||||
@@ -70,72 +82,96 @@ export function Sidebar() {
|
||||
setShowClosedAccountsPref(!showClosedAccounts);
|
||||
};
|
||||
|
||||
const containerRef = useResizeObserver(rect => {
|
||||
setSidebarWidth(rect.width);
|
||||
});
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: SIDEBAR_WIDTH,
|
||||
color: theme.sidebarItemText,
|
||||
backgroundColor: theme.sidebarBackground,
|
||||
'& .float': {
|
||||
opacity: isFloating ? 1 : 0,
|
||||
transition: 'opacity .25s, width .25s',
|
||||
width: hasWindowButtons || isFloating ? null : 0,
|
||||
},
|
||||
'&:hover .float': {
|
||||
opacity: 1,
|
||||
width: hasWindowButtons ? null : 'auto',
|
||||
},
|
||||
flex: 1,
|
||||
...styles.darkScrollbar,
|
||||
<Resizable
|
||||
defaultSize={{
|
||||
width: sidebarWidth,
|
||||
height: '100%',
|
||||
}}
|
||||
maxWidth={MAX_SIDEBAR_WIDTH}
|
||||
minWidth={MIN_SIDEBAR_WIDTH}
|
||||
enable={{
|
||||
top: false,
|
||||
right: true,
|
||||
bottom: false,
|
||||
left: false,
|
||||
topRight: false,
|
||||
bottomRight: false,
|
||||
bottomLeft: false,
|
||||
topLeft: false,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
innerRef={containerRef}
|
||||
style={{
|
||||
paddingTop: 35,
|
||||
height: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: '0 8px 23px 20px',
|
||||
transition: 'padding .4s',
|
||||
...(hasWindowButtons && {
|
||||
paddingTop: 20,
|
||||
justifyContent: 'flex-start',
|
||||
}),
|
||||
color: theme.sidebarItemText,
|
||||
height: '100%',
|
||||
backgroundColor: theme.sidebarBackground,
|
||||
'& .float': {
|
||||
opacity: isFloating ? 1 : 0,
|
||||
transition: 'opacity .25s, width .25s',
|
||||
width: hasWindowButtons || isFloating ? null : 0,
|
||||
},
|
||||
'&:hover .float': {
|
||||
opacity: 1,
|
||||
width: hasWindowButtons ? null : 'auto',
|
||||
},
|
||||
flex: 1,
|
||||
...styles.darkScrollbar,
|
||||
}}
|
||||
>
|
||||
<EditableBudgetName />
|
||||
|
||||
<View style={{ flex: 1, flexDirection: 'row' }} />
|
||||
|
||||
{!sidebar.alwaysFloats && (
|
||||
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ overflow: 'auto' }}>
|
||||
<Item title="Budget" Icon={SvgWallet} to="/budget" />
|
||||
<Item title="Reports" Icon={SvgReports} to="/reports" />
|
||||
|
||||
<Item title="Schedules" Icon={SvgCalendar} to="/schedules" />
|
||||
|
||||
<Tools />
|
||||
|
||||
<View
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: theme.sidebarItemBackgroundHover,
|
||||
marginTop: 15,
|
||||
flexShrink: 0,
|
||||
paddingTop: 35,
|
||||
height: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: '0 8px 23px 20px',
|
||||
transition: 'padding .4s',
|
||||
...(hasWindowButtons && {
|
||||
paddingTop: 20,
|
||||
justifyContent: 'flex-start',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<EditableBudgetName />
|
||||
|
||||
<Accounts
|
||||
onAddAccount={onAddAccount}
|
||||
onToggleClosedAccounts={onToggleClosedAccounts}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
<View style={{ flex: 1, flexDirection: 'row' }} />
|
||||
|
||||
{!sidebar.alwaysFloats && (
|
||||
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ overflow: 'auto' }}>
|
||||
<Item title="Budget" Icon={SvgWallet} to="/budget" />
|
||||
<Item title="Reports" Icon={SvgReports} to="/reports" />
|
||||
|
||||
<Item title="Schedules" Icon={SvgCalendar} to="/schedules" />
|
||||
|
||||
<Tools />
|
||||
|
||||
<View
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: theme.sidebarItemBackgroundHover,
|
||||
marginTop: 15,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Accounts
|
||||
onAddAccount={onAddAccount}
|
||||
onToggleClosedAccounts={onToggleClosedAccounts}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { SIDEBAR_WIDTH, Sidebar } from './Sidebar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { useSidebar } from './SidebarProvider';
|
||||
|
||||
export function FloatableSidebar() {
|
||||
@@ -14,25 +16,30 @@ export function FloatableSidebar() {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
const sidebarShouldFloat = floatingSidebar || sidebar.alwaysFloats;
|
||||
const debouncedHideSidebar = useDebounceCallback(
|
||||
() => sidebar.setHidden(true),
|
||||
350,
|
||||
);
|
||||
|
||||
return isNarrowWidth ? null : (
|
||||
<View
|
||||
onMouseOver={
|
||||
sidebarShouldFloat
|
||||
? e => {
|
||||
debouncedHideSidebar.cancel();
|
||||
e.stopPropagation();
|
||||
sidebar.setHidden(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
sidebarShouldFloat ? () => sidebar.setHidden(true) : undefined
|
||||
sidebarShouldFloat ? () => debouncedHideSidebar() : undefined
|
||||
}
|
||||
style={{
|
||||
position: sidebarShouldFloat ? 'absolute' : undefined,
|
||||
top: 12,
|
||||
top: 8,
|
||||
// If not floating, the -50 takes into account the transform below
|
||||
bottom: sidebarShouldFloat ? 12 : -50,
|
||||
bottom: sidebarShouldFloat ? 8 : -50,
|
||||
zIndex: 1001,
|
||||
borderRadius: sidebarShouldFloat ? '0 6px 6px 0' : 0,
|
||||
overflow: 'hidden',
|
||||
@@ -40,12 +47,10 @@ export function FloatableSidebar() {
|
||||
!sidebarShouldFloat || sidebar.hidden
|
||||
? 'none'
|
||||
: '0 15px 30px 0 rgba(0,0,0,0.25), 0 3px 15px 0 rgba(0,0,0,.5)',
|
||||
transform: `translateY(${!sidebarShouldFloat ? -12 : 0}px)
|
||||
transform: `translateY(${!sidebarShouldFloat ? -8 : 0}px)
|
||||
translateX(${
|
||||
sidebarShouldFloat && sidebar.hidden
|
||||
? -SIDEBAR_WIDTH
|
||||
: 0
|
||||
}px)`,
|
||||
sidebarShouldFloat && sidebar.hidden ? '-100' : '0'
|
||||
}%)`,
|
||||
transition:
|
||||
'transform .5s, box-shadow .5s, border-radius .5s, bottom .5s',
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
type UIEvent,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type MutableRefObject,
|
||||
} from 'react';
|
||||
import { useStore } from 'react-redux';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
import { type Binding } from './spreadsheet';
|
||||
import { type FormatType, useFormat } from './spreadsheet/useFormat';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
import { IntersectionBoundary } from './tooltips';
|
||||
|
||||
export const ROW_HEIGHT = 32;
|
||||
|
||||
@@ -276,16 +276,13 @@ type RowProps = ComponentProps<typeof View> & {
|
||||
inset?: number;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
export function Row({
|
||||
inset = 0,
|
||||
collapsed,
|
||||
children,
|
||||
height,
|
||||
style,
|
||||
...nativeProps
|
||||
}: RowProps) {
|
||||
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
{ inset = 0, collapsed, children, height, style, ...nativeProps },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
height: height || ROW_HEIGHT,
|
||||
@@ -302,7 +299,7 @@ export function Row({
|
||||
{inset !== 0 && <Field width={inset} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const inputCellStyle = {
|
||||
padding: '5px 3px',
|
||||
@@ -900,9 +897,8 @@ type TableProps<T extends TableItem = TableItem> = {
|
||||
loadMore?: () => void;
|
||||
style?: CSSProperties;
|
||||
navigator?: ReturnType<typeof useTableNavigator<T>>;
|
||||
listRef?: unknown;
|
||||
listContainerRef?: MutableRefObject<HTMLDivElement>;
|
||||
onScroll?: () => void;
|
||||
allowPopupsEscape?: boolean;
|
||||
isSelected?: (id: TableItem['id']) => boolean;
|
||||
saveScrollWidth?: (parent, child) => void;
|
||||
};
|
||||
@@ -924,9 +920,9 @@ export const Table = forwardRef(
|
||||
style,
|
||||
navigator,
|
||||
onScroll,
|
||||
allowPopupsEscape,
|
||||
isSelected,
|
||||
saveScrollWidth,
|
||||
listContainerRef,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -942,7 +938,8 @@ export const Table = forwardRef(
|
||||
|
||||
const { onEdit, editingId, focusedField, getNavigatorProps } = navigator;
|
||||
const list = useRef(null);
|
||||
const listContainer = useRef(null);
|
||||
const listContainerInnerRef = useRef<HTMLDivElement>(null);
|
||||
const listContainer = listContainerRef || listContainerInnerRef;
|
||||
const scrollContainer = useRef(null);
|
||||
const initialScrollTo = useRef(null);
|
||||
const listInitialized = useRef(false);
|
||||
@@ -1144,37 +1141,33 @@ export const Table = forwardRef(
|
||||
}
|
||||
|
||||
return (
|
||||
<IntersectionBoundary.Provider
|
||||
value={!allowPopupsEscape && listContainer}
|
||||
>
|
||||
<AvoidRefocusScrollProvider>
|
||||
<FixedSizeList
|
||||
ref={list}
|
||||
header={contentHeader}
|
||||
innerRef={listContainer}
|
||||
outerRef={scrollContainer}
|
||||
width={width}
|
||||
height={height}
|
||||
renderRow={renderRow}
|
||||
itemCount={count || items.length}
|
||||
itemSize={rowHeight - 1}
|
||||
itemKey={
|
||||
getItemKey || ((index: number) => items[index].id)
|
||||
}
|
||||
indexForKey={key =>
|
||||
items.findIndex(item => item.id === key)
|
||||
}
|
||||
initialScrollOffset={
|
||||
initialScrollTo.current
|
||||
? getScrollOffset(height, initialScrollTo.current)
|
||||
: 0
|
||||
}
|
||||
overscanCount={5}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
</AvoidRefocusScrollProvider>
|
||||
</IntersectionBoundary.Provider>
|
||||
<AvoidRefocusScrollProvider>
|
||||
<FixedSizeList
|
||||
ref={list}
|
||||
header={contentHeader}
|
||||
innerRef={listContainer}
|
||||
outerRef={scrollContainer}
|
||||
width={width}
|
||||
height={height}
|
||||
renderRow={renderRow}
|
||||
itemCount={count || items.length}
|
||||
itemSize={rowHeight - 1}
|
||||
itemKey={
|
||||
getItemKey || ((index: number) => items[index].id)
|
||||
}
|
||||
indexForKey={key =>
|
||||
items.findIndex(item => item.id === key)
|
||||
}
|
||||
initialScrollOffset={
|
||||
initialScrollTo.current
|
||||
? getScrollOffset(height, initialScrollTo.current)
|
||||
: 0
|
||||
}
|
||||
overscanCount={5}
|
||||
onItemsRendered={onItemsRendered}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
</AvoidRefocusScrollProvider>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
createRef,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
type ContextType,
|
||||
} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { type CSSProperties, styles, theme } from '../style';
|
||||
|
||||
export const IntersectionBoundary = createContext<RefObject<HTMLElement>>(null);
|
||||
|
||||
type TooltipPosition =
|
||||
| 'top'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'bottom-stretch'
|
||||
| 'top-stretch'
|
||||
| 'bottom-center'
|
||||
| 'top-center'
|
||||
| 'left-center'
|
||||
| 'right';
|
||||
|
||||
type TooltipProps = {
|
||||
position?: TooltipPosition;
|
||||
onClose?: () => void;
|
||||
forceLayout?: boolean;
|
||||
forceTop?: number;
|
||||
ignoreBoundary?: boolean;
|
||||
targetRect?: DOMRect;
|
||||
offset?: number;
|
||||
style?: CSSProperties;
|
||||
width?: number;
|
||||
children: ReactNode;
|
||||
targetHeight?: number;
|
||||
};
|
||||
type MutableDomRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// @deprecated: please use `Tooltip` component in `common` folder
|
||||
export class Tooltip extends Component<TooltipProps> {
|
||||
static contextType = IntersectionBoundary;
|
||||
position: TooltipPosition;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
cleanup: () => void;
|
||||
target: HTMLDivElement;
|
||||
context: ContextType<typeof IntersectionBoundary> = this.context; // assign type to context without using declare.
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.position = props.position || 'bottom-right';
|
||||
this.contentRef = createRef<HTMLDivElement>();
|
||||
}
|
||||
|
||||
isHTMLElement(element: unknown): element is HTMLElement {
|
||||
return element instanceof HTMLElement;
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.layout();
|
||||
|
||||
const pointerDownHandler = e => {
|
||||
let node = e.target;
|
||||
|
||||
while (node && node !== document.documentElement) {
|
||||
// Allow clicking reach popover that mount outside of
|
||||
// tooltips. Might need to think about this more, like what
|
||||
// kind of things can be click that shouldn't close a tooltip?
|
||||
if (node.dataset.istooltip || node.dataset.reachPopover != null) {
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
if (node === document.documentElement) {
|
||||
this.props.onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
const escHandler = e => {
|
||||
if (e.key === 'Escape') {
|
||||
this.props.onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.document.addEventListener('pointerdown', pointerDownHandler, false);
|
||||
this.contentRef.current?.addEventListener('keydown', escHandler, false);
|
||||
|
||||
this.cleanup = () => {
|
||||
window.document.removeEventListener(
|
||||
'pointerdown',
|
||||
pointerDownHandler,
|
||||
false,
|
||||
);
|
||||
this.contentRef.current?.removeEventListener(
|
||||
'keydown',
|
||||
escHandler,
|
||||
false,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.getContainer()) {
|
||||
this.setup();
|
||||
} else {
|
||||
// TODO: write comment :)
|
||||
this.forceUpdate(() => {
|
||||
if (this.getContainer()) {
|
||||
this.setup();
|
||||
} else {
|
||||
console.log('Warning: could not mount tooltip, container missing');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
// If providing the target rect manually, we can dynamically
|
||||
// update to it. We can't do this if we are reading directly from
|
||||
// the DOM since we don't know when it's updated.
|
||||
if (
|
||||
prevProps.targetRect !== this.props.targetRect ||
|
||||
prevProps.forceTop !== this.props.forceTop ||
|
||||
this.props.forceLayout
|
||||
) {
|
||||
this.layout();
|
||||
}
|
||||
}
|
||||
|
||||
getContainer(): HTMLElement {
|
||||
const { ignoreBoundary = false } = this.props;
|
||||
|
||||
if (!ignoreBoundary && this.context) {
|
||||
return this.context.current;
|
||||
}
|
||||
return document.body;
|
||||
}
|
||||
|
||||
getBoundsContainer() {
|
||||
// If the container is a scrollable element, we want to do all the
|
||||
// bounds checking on the parent DOM element instead
|
||||
const container = this.getContainer();
|
||||
|
||||
if (
|
||||
container.parentNode &&
|
||||
this.isHTMLElement(container.parentNode) &&
|
||||
container.parentNode.style.overflow === 'auto'
|
||||
) {
|
||||
return container.parentNode;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
layout() {
|
||||
const { targetRect, offset = 0 } = this.props;
|
||||
const contentEl = this.contentRef.current;
|
||||
if (!contentEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const box = contentEl.getBoundingClientRect();
|
||||
|
||||
const anchorEl = this.target.parentNode;
|
||||
|
||||
let anchorRect: MutableDomRect | undefined =
|
||||
targetRect ||
|
||||
(this.isHTMLElement(anchorEl)
|
||||
? anchorEl?.getBoundingClientRect()
|
||||
: undefined);
|
||||
|
||||
if (!anchorRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy it so we can mutate it
|
||||
anchorRect = {
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
};
|
||||
|
||||
const container = this.getBoundsContainer();
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
anchorRect.left -= containerRect.left;
|
||||
anchorRect.top -= containerRect.top;
|
||||
|
||||
// This is a hack, but allow consumers to force a top position if
|
||||
// they already know it. This allows them to provide consistent
|
||||
// updates. We should generalize this and `targetRect`
|
||||
if (this.props.forceTop) {
|
||||
anchorRect.top = this.props.forceTop;
|
||||
} else {
|
||||
const boxHeight = box.height + offset;
|
||||
const testTop = anchorRect.top - boxHeight;
|
||||
const testBottom = anchorRect.top + anchorRect.height + boxHeight;
|
||||
|
||||
if (
|
||||
// If it doesn't fit above it, switch it to below
|
||||
(this.position.indexOf('top') !== -1 && testTop < containerRect.top) ||
|
||||
// If it doesn't fit below it, switch it above only if it does
|
||||
// fit above it
|
||||
(this.position.indexOf('bottom') !== -1 &&
|
||||
testBottom > containerRect.height &&
|
||||
testTop > 0)
|
||||
) {
|
||||
// Invert the position
|
||||
this.position = this.getOppositePosition(this.position);
|
||||
}
|
||||
|
||||
anchorRect.top += container.scrollTop;
|
||||
}
|
||||
|
||||
const style = this.getStyleForPosition(
|
||||
this.position,
|
||||
box,
|
||||
anchorRect,
|
||||
this.getContainer().getBoundingClientRect(),
|
||||
offset,
|
||||
);
|
||||
|
||||
contentEl.style.top = style.top;
|
||||
contentEl.style.bottom = style.bottom;
|
||||
contentEl.style.left = style.left;
|
||||
contentEl.style.right = style.right;
|
||||
contentEl.style.width = style.width;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.cleanup) {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
getOppositePosition(position) {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
case 'top-left':
|
||||
return 'bottom';
|
||||
case 'top-right':
|
||||
return 'bottom-right';
|
||||
case 'bottom':
|
||||
case 'bottom-left':
|
||||
return 'top';
|
||||
case 'bottom-right':
|
||||
return 'top-right';
|
||||
case 'bottom-stretch':
|
||||
return 'top-stretch';
|
||||
case 'top-stretch':
|
||||
return 'bottom-stretch';
|
||||
case 'bottom-center':
|
||||
return 'top-center';
|
||||
case 'top-center':
|
||||
return 'bottom-center';
|
||||
case 'right':
|
||||
return 'right';
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
getStyleForPosition(position, boxRect, anchorRect, containerRect, offset) {
|
||||
const style = {
|
||||
top: 'inherit',
|
||||
bottom: 'inherit',
|
||||
left: 'inherit',
|
||||
right: 'inherit',
|
||||
width: undefined as string | undefined,
|
||||
};
|
||||
|
||||
if (
|
||||
position === 'top' ||
|
||||
position === 'top-right' ||
|
||||
position === 'top-left'
|
||||
) {
|
||||
style.top = anchorRect.top - boxRect.height - offset + 'px';
|
||||
if (position === 'top-right') {
|
||||
style.left =
|
||||
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
|
||||
} else {
|
||||
style.left = anchorRect.left + 'px';
|
||||
// style.right = 0;
|
||||
}
|
||||
} else if (
|
||||
position === 'bottom' ||
|
||||
position === 'bottom-right' ||
|
||||
position === 'bottom-left'
|
||||
) {
|
||||
style.top = anchorRect.top + anchorRect.height + offset + 'px';
|
||||
if (position === 'bottom-right') {
|
||||
style.left =
|
||||
anchorRect.left + (anchorRect.width - boxRect.width) + 'px';
|
||||
} else {
|
||||
style.left = anchorRect.left + 'px';
|
||||
// style.right = 0;
|
||||
}
|
||||
} else if (position === 'bottom-center') {
|
||||
style.top = anchorRect.top + anchorRect.height + offset + 'px';
|
||||
style.left =
|
||||
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
|
||||
} else if (position === 'top-center') {
|
||||
style.top = anchorRect.top - boxRect.height - offset + 'px';
|
||||
style.left =
|
||||
anchorRect.left - (boxRect.width - anchorRect.width) / 2 + 'px';
|
||||
} else if (position === 'left-center') {
|
||||
style.top =
|
||||
anchorRect.top - (boxRect.height - anchorRect.height) / 2 + 'px';
|
||||
style.left = anchorRect.left - boxRect.width + 'px';
|
||||
} else if (position === 'top-stretch') {
|
||||
style.bottom = containerRect.height - anchorRect.top + offset + 'px';
|
||||
style.left = anchorRect.left + 'px';
|
||||
style.width = anchorRect.width + 'px';
|
||||
} else if (position === 'bottom-stretch') {
|
||||
style.top = anchorRect.top + anchorRect.height + offset + 'px';
|
||||
style.left = anchorRect.left + 'px';
|
||||
style.width = anchorRect.width + 'px';
|
||||
} else if (position === 'right') {
|
||||
style.top = anchorRect.top + 'px';
|
||||
style.left = anchorRect.left + anchorRect.width + offset + 'px';
|
||||
} else {
|
||||
throw new Error('Invalid position for Tooltip: ' + position);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, width, style } = this.props;
|
||||
|
||||
const contentStyle = {
|
||||
position: 'absolute',
|
||||
zIndex: 3000,
|
||||
|
||||
padding: 5,
|
||||
width,
|
||||
...styles.shadowLarge,
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.menuBackground,
|
||||
color: theme.menuItemText,
|
||||
// opacity: 0,
|
||||
// transition: 'transform .1s, opacity .1s',
|
||||
// transitionTimingFunction: 'ease-out'
|
||||
};
|
||||
|
||||
// const enteredStyle = { opacity: 1, transform: 'none' };
|
||||
|
||||
if (!this.getContainer()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={el => (this.target = el)}>
|
||||
{ReactDOM.createPortal(
|
||||
<div
|
||||
className={`${css(contentStyle, style, styles.darkScrollbar)}`}
|
||||
ref={this.contentRef}
|
||||
data-testid={this.props['data-testid'] || 'tooltip'}
|
||||
data-istooltip
|
||||
onClick={e => {
|
||||
// Click events inside a tooltip (e.g. when selecting a menu item) will bubble up
|
||||
// through the portal to parents in the React tree (as opposed to DOM parents).
|
||||
// This is undesirable. For example, clicking on a category group on a budget sheet
|
||||
// toggles that group, and so would clicking on a menu item in the settings menu
|
||||
// of that category group if the click event wasn't stopped from bubbling up.
|
||||
// This issue could be handled in different ways, but I think stopping propagation
|
||||
// here is sane; I can't see a scenario where you would want to take advantage of
|
||||
// click propagation from a tooltip back to its React parent.
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
this.getContainer(),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
|
||||
import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
|
||||
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
|
||||
import { Button } from '../common/Button';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { getStatusProps } from '../schedules/StatusBadge';
|
||||
@@ -79,7 +80,6 @@ import {
|
||||
Table,
|
||||
UnexposedCellContent,
|
||||
} from '../table';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
function getDisplayValue(obj, name) {
|
||||
return obj ? obj[name] : '';
|
||||
@@ -342,6 +342,7 @@ function StatusCell({
|
||||
selected,
|
||||
status,
|
||||
isChild,
|
||||
isPreview,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}) {
|
||||
@@ -384,12 +385,19 @@ function StatusCell({
|
||||
border: '1px solid transparent',
|
||||
borderRadius: 50,
|
||||
':focus': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 2px ' + theme.formInputBorderSelected,
|
||||
...(isPreview
|
||||
? {
|
||||
boxShadow: 'none',
|
||||
}
|
||||
: {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 2px ' + theme.formInputBorderSelected,
|
||||
}),
|
||||
},
|
||||
cursor: isClearedField ? 'pointer' : 'default',
|
||||
...(isChild && { visibility: 'hidden' }),
|
||||
}}
|
||||
disabled={isPreview || isChild}
|
||||
onEdit={() => onEdit(id, 'cleared')}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
@@ -604,17 +612,17 @@ function PayeeIcons({
|
||||
transferAccount,
|
||||
onNavigateToTransferAccount,
|
||||
onNavigateToSchedule,
|
||||
children,
|
||||
}) {
|
||||
const scheduleId = transaction.schedule;
|
||||
const scheduleData = useCachedSchedules();
|
||||
const schedule = scheduleData
|
||||
? scheduleData.schedules.find(s => s.id === scheduleId)
|
||||
: null;
|
||||
const schedule =
|
||||
scheduleId && scheduleData
|
||||
? scheduleData.schedules.find(s => s.id === scheduleId)
|
||||
: null;
|
||||
|
||||
if (schedule == null && transferAccount == null) {
|
||||
// Neither a valid scheduled transaction nor a transfer.
|
||||
return children;
|
||||
return null;
|
||||
}
|
||||
|
||||
const buttonStyle = {
|
||||
@@ -673,6 +681,7 @@ function PayeeIcons({
|
||||
}
|
||||
|
||||
const Transaction = memo(function Transaction({
|
||||
allTransactions,
|
||||
transaction: originalTransaction,
|
||||
subtransactions,
|
||||
editing,
|
||||
@@ -703,9 +712,12 @@ const Transaction = memo(function Transaction({
|
||||
onNavigateToTransferAccount,
|
||||
onNavigateToSchedule,
|
||||
onNotesTagClick,
|
||||
splitError,
|
||||
listContainerRef,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const dispatchSelected = useSelectedDispatch();
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit);
|
||||
const [prevTransaction, setPrevTransaction] = useState(originalTransaction);
|
||||
@@ -873,8 +885,31 @@ const Transaction = memo(function Transaction({
|
||||
? balance
|
||||
: balance + (_inverse ? -1 : 1) * amount;
|
||||
|
||||
// Ok this entire logic is a dirty, dirty hack.. but let me explain.
|
||||
// Problem: the split-error Popover (which has the buttons to distribute/add split)
|
||||
// renders before schedules are added to the table. After schedules finally load
|
||||
// the entire table gets pushed down. But the Popover does not re-calculate
|
||||
// its positioning. This is because there is nothing in react-aria that would be
|
||||
// watching for the position of the trigger element.
|
||||
// Solution: when transactions (this includes schedules) change - we increment
|
||||
// a variable (with a small delay in order for the next render cycle to pick up
|
||||
// the change instead of the current). We pass the integer to the Popover which
|
||||
// causes it to re-calculate the positioning. Thus fixing the problem.
|
||||
const [updateId, setUpdateId] = useState(1);
|
||||
useEffect(() => {
|
||||
// The hack applies to only transactions with split errors
|
||||
if (!splitError) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setUpdateId(state => state + 1);
|
||||
}, 1);
|
||||
}, [splitError, allTransactions]);
|
||||
|
||||
return (
|
||||
<Row
|
||||
ref={triggerRef}
|
||||
style={{
|
||||
backgroundColor: selected
|
||||
? theme.tableRowBackgroundHighlight
|
||||
@@ -901,6 +936,21 @@ const Transaction = memo(function Transaction({
|
||||
...(_unmatched && { opacity: 0.5 }),
|
||||
}}
|
||||
>
|
||||
{splitError && listContainerRef.current && (
|
||||
<Popover
|
||||
arrowSize={updateId}
|
||||
triggerRef={triggerRef}
|
||||
isOpen
|
||||
isNonModal
|
||||
style={{ width: 375, padding: 5, maxHeight: '38px !important' }}
|
||||
shouldFlip={false}
|
||||
placement="bottom end"
|
||||
UNSTABLE_portalContainer={listContainerRef.current}
|
||||
>
|
||||
{splitError}
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{isChild && (
|
||||
<Field
|
||||
/* Checkmark blank placeholder for Child transaction */
|
||||
@@ -1571,6 +1621,7 @@ function NewTransaction({
|
||||
function TransactionTableInner({
|
||||
tableNavigator,
|
||||
tableRef,
|
||||
listContainerRef,
|
||||
dateFormat = 'MM/dd/yyyy',
|
||||
newNavigator,
|
||||
renderEmpty,
|
||||
@@ -1617,7 +1668,7 @@ function TransactionTableInner({
|
||||
}
|
||||
}, [isAddingPrev, props.isAdding, newNavigator]);
|
||||
|
||||
const renderRow = ({ item, index, position, editing }) => {
|
||||
const renderRow = ({ item, index, editing }) => {
|
||||
const {
|
||||
transactions,
|
||||
selectedItems,
|
||||
@@ -1661,15 +1712,39 @@ function TransactionTableInner({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasSplitError && (
|
||||
<Tooltip
|
||||
position="bottom-right"
|
||||
width={350}
|
||||
forceTop={position}
|
||||
forceLayout={true}
|
||||
style={{ transform: 'translate(-5px, 2px)' }}
|
||||
>
|
||||
<Transaction
|
||||
allTransactions={props.transactions}
|
||||
editing={editing}
|
||||
transaction={trans}
|
||||
showAccount={showAccount}
|
||||
showCategory={showCategory}
|
||||
showBalance={showBalances}
|
||||
showCleared={showCleared}
|
||||
selected={selected}
|
||||
highlighted={false}
|
||||
added={isNew?.(trans.id)}
|
||||
expanded={isExpanded?.(trans.id)}
|
||||
matched={isMatched?.(trans.id)}
|
||||
showZeroInDeposit={isChildDeposit}
|
||||
balance={balances?.[trans.id]?.balance}
|
||||
focusedField={editing && tableNavigator.focusedField}
|
||||
accounts={accounts}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
onEdit={tableNavigator.onEdit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onSplit={props.onSplit}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNotesTagClick={onNotesTagClick}
|
||||
splitError={
|
||||
hasSplitError && (
|
||||
<TransactionError
|
||||
error={error}
|
||||
isDeposit={isChildDeposit}
|
||||
@@ -1679,40 +1754,10 @@ function TransactionTableInner({
|
||||
}
|
||||
canDistributeRemainder={emptyChildTransactions.length > 0}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Transaction
|
||||
editing={editing}
|
||||
transaction={trans}
|
||||
showAccount={showAccount}
|
||||
showCategory={showCategory}
|
||||
showBalance={showBalances}
|
||||
showCleared={showCleared}
|
||||
selected={selected}
|
||||
highlighted={false}
|
||||
added={isNew?.(trans.id)}
|
||||
expanded={isExpanded?.(trans.id)}
|
||||
matched={isMatched?.(trans.id)}
|
||||
showZeroInDeposit={isChildDeposit}
|
||||
balance={balances?.[trans.id]?.balance}
|
||||
focusedField={editing && tableNavigator.focusedField}
|
||||
accounts={accounts}
|
||||
categoryGroups={categoryGroups}
|
||||
payees={payees}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
onEdit={tableNavigator.onEdit}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
onSplit={props.onSplit}
|
||||
onManagePayees={props.onManagePayees}
|
||||
onCreatePayee={props.onCreatePayee}
|
||||
onToggleSplit={props.onToggleSplit}
|
||||
onNavigateToTransferAccount={onNavigateToTransferAccount}
|
||||
onNavigateToSchedule={onNavigateToSchedule}
|
||||
onNotesTagClick={onNotesTagClick}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
listContainerRef={listContainerRef}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1789,6 +1834,7 @@ function TransactionTableInner({
|
||||
<Table
|
||||
navigator={tableNavigator}
|
||||
ref={tableRef}
|
||||
listContainerRef={listContainerRef}
|
||||
items={props.transactions}
|
||||
renderItem={renderRow}
|
||||
renderEmpty={renderEmpty}
|
||||
@@ -1825,6 +1871,7 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
const prevSplitsExpanded = useRef(null);
|
||||
|
||||
const tableRef = useRef(null);
|
||||
const listContainerRef = useRef(null);
|
||||
const mergedRef = useMergedRefs(tableRef, ref);
|
||||
|
||||
const transactions = useMemo(() => {
|
||||
@@ -1965,7 +2012,7 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
);
|
||||
|
||||
if (isPreviewId(item.id)) {
|
||||
fields = ['select', 'cleared'];
|
||||
fields = ['select'];
|
||||
}
|
||||
if (isTemporaryId(item.id)) {
|
||||
// You can't focus the select/delete button of temporary
|
||||
@@ -2237,6 +2284,7 @@ export const TransactionTable = forwardRef((props, ref) => {
|
||||
return (
|
||||
<TransactionTableInner
|
||||
tableRef={mergedRef}
|
||||
listContainerRef={listContainerRef}
|
||||
{...props}
|
||||
transactions={transactions}
|
||||
transactionMap={transactionMap}
|
||||
|
||||
@@ -816,9 +816,7 @@ describe('Transactions', () => {
|
||||
expect(getTransactions()[1].amount).toBe(0);
|
||||
expectErrorToExist(getTransactions().slice(0, 2));
|
||||
|
||||
const toolbars = container.querySelectorAll(
|
||||
'[data-testid="transaction-error"]',
|
||||
);
|
||||
const toolbars = screen.queryAllByTestId('transaction-error');
|
||||
// Make sure the toolbar has appeared
|
||||
expect(toolbars.length).toBe(1);
|
||||
const toolbar = toolbars[0];
|
||||
@@ -925,9 +923,7 @@ describe('Transactions', () => {
|
||||
expect(getTransactions().length).toBe(5);
|
||||
await userEvent.click(screen.getByTestId('split-transaction-button'));
|
||||
await waitForAutocomplete();
|
||||
await userEvent.click(
|
||||
container.querySelector('[data-testid="add-split-button"]'),
|
||||
);
|
||||
await userEvent.click(screen.getByTestId('add-split-button'));
|
||||
expect(getTransactions().length).toBe(7);
|
||||
|
||||
// The debit field should show the zeros
|
||||
|
||||
18
packages/desktop-client/src/icons/v2/DownAndRightArrow.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
export const SvgDownAndRightArrow = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
style={{
|
||||
color: 'inherit',
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M4.092 3.658 1.267 6.484l.708.708.708.708 1.609-1.608L5.9 4.684V7.4c.001 2.838.022 3.466.132 3.96.393 1.766 1.732 2.972 3.985 3.59.238.065.53.133.65.151.119.018 1.229.055 2.466.082 1.238.028 2.458.06 2.712.072l.462.021-1.561 1.562-1.562 1.562.708.708.708.708 2.7-2.699 2.7-2.7v-.5l-2.7-2.7-2.7-2.7-.7.7-.7.699 1.675 1.679 1.675 1.678-.617-.019c-.339-.011-1.584-.043-2.766-.071-1.191-.028-2.232-.067-2.334-.087a6.822 6.822 0 0 1-1.283-.426c-.754-.356-1.201-.777-1.447-1.365-.167-.399-.17-.463-.17-3.649V4.684L9.55 6.3l1.617 1.616.708-.708.708-.708L9.75 3.667 6.917.833 4.092 3.658" /></svg>
|
||||
|
After Width: | Height: | Size: 658 B |
@@ -16,6 +16,7 @@ export { SvgCheck } from './Check';
|
||||
export { SvgCloudUnknown } from './CloudUnknown';
|
||||
export { SvgCloudUpload } from './CloudUpload';
|
||||
export { SvgCustomNotesPaper } from './CustomNotesPaper';
|
||||
export { SvgDownAndRightArrow } from './DownAndRightArrow';
|
||||
export { SvgDownloadThickBottom } from './DownloadThickBottom';
|
||||
export { SvgEditSkull1 } from './EditSkull1';
|
||||
export { SvgFavoriteStar } from './FavoriteStar';
|
||||
|
||||
@@ -183,6 +183,7 @@ export const pillTextHighlighted = colorPalette.purple200;
|
||||
export const pillBorder = colorPalette.navy700;
|
||||
export const pillBorderDark = pillBorder;
|
||||
export const pillBackgroundSelected = colorPalette.purple600;
|
||||
export const pillBackgroundHover = 'rgba(200, 200, 200, .3)';
|
||||
export const pillTextSelected = colorPalette.navy150;
|
||||
export const pillBorderSelected = colorPalette.purple400;
|
||||
export const pillTextSubdued = colorPalette.navy500;
|
||||
|
||||
@@ -182,6 +182,7 @@ export const pillTextHighlighted = colorPalette.purple600;
|
||||
export const pillBorder = colorPalette.navy150;
|
||||
export const pillBorderDark = colorPalette.navy300;
|
||||
export const pillBackgroundSelected = colorPalette.blue150;
|
||||
export const pillBackgroundHover = 'rgba(100, 100, 100, .15)';
|
||||
export const pillTextSelected = colorPalette.blue900;
|
||||
export const pillBorderSelected = colorPalette.purple500;
|
||||
export const pillTextSubdued = colorPalette.navy200;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const pillTextHighlighted = colorPalette.purple600;
|
||||
export const pillBorder = colorPalette.navy150;
|
||||
export const pillBorderDark = colorPalette.navy300;
|
||||
export const pillBackgroundSelected = colorPalette.blue150;
|
||||
export const pillBackgroundHover = 'rgba(100, 100, 100, .15)';
|
||||
export const pillTextSelected = colorPalette.blue900;
|
||||
export const pillBorderSelected = colorPalette.purple500;
|
||||
export const pillTextSubdued = colorPalette.navy200;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const pillTextHighlighted = colorPalette.purple200;
|
||||
export const pillBorder = colorPalette.gray500;
|
||||
export const pillBorderDark = pillBorder;
|
||||
export const pillBackgroundSelected = colorPalette.purple600;
|
||||
export const pillBackgroundHover = 'rgba(200, 200, 200, .3)';
|
||||
export const pillTextSelected = colorPalette.gray150;
|
||||
export const pillBorderSelected = colorPalette.purple300;
|
||||
export const pillTextSubdued = colorPalette.gray500;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const isDev = require('electron-is-dev');
|
||||
|
||||
let window;
|
||||
|
||||
function openAboutWindow() {
|
||||
if (window != null) {
|
||||
window.focus();
|
||||
return window;
|
||||
}
|
||||
|
||||
window = new BrowserWindow({
|
||||
width: 290,
|
||||
height: process.platform === 'win32' ? 255 : 240,
|
||||
show: true,
|
||||
resizable: isDev,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
window.setBackgroundColor('white');
|
||||
window.setTitle('');
|
||||
window.loadURL(`file://${__dirname}/about/about.html`);
|
||||
|
||||
window.once('closed', () => {
|
||||
window = null;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { openAboutWindow, getWindow: () => window };
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style type="text/css">
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
|
||||
color: #303030;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,94 +0,0 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
const root = document.querySelector('#root');
|
||||
|
||||
const { version: appVersion } = ipcRenderer.sendSync('get-bootstrap-data');
|
||||
|
||||
const iconPath = __dirname + '/../icons/icon.png';
|
||||
|
||||
root.style.display = 'flex';
|
||||
root.style.flexDirection = 'column';
|
||||
root.style.alignItems = 'center';
|
||||
root.style.padding = '10px';
|
||||
root.innerHTML = `
|
||||
<img src="${iconPath}" width="60" height="60" />
|
||||
<strong style="font-size:14px; padding-top: 15px">Actual</strong>
|
||||
<div style="padding-bottom:15px; padding-top: 5px">Version ${appVersion}</div>
|
||||
<div id="container">
|
||||
<div id="update-check"><button>Check for updates</button></div>
|
||||
<div id="apply-update"><button>Restart to Update</button></div>
|
||||
<div id="success"></div>
|
||||
<div id="error"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = root.querySelector('#container');
|
||||
container.style.height = '45px';
|
||||
container.style.textAlign = 'center';
|
||||
|
||||
const updateEl = root.querySelector('#update-check');
|
||||
const applyUpdateEl = root.querySelector('#apply-update');
|
||||
applyUpdateEl.style.display = 'none';
|
||||
const successEl = root.querySelector('#success');
|
||||
successEl.style.display = 'none';
|
||||
successEl.style.textAlign = 'center';
|
||||
const errorEl = root.querySelector('#error');
|
||||
errorEl.style.display = 'none';
|
||||
|
||||
root.querySelector('#update-check button').addEventListener('click', () => {
|
||||
ipcRenderer.send('check-for-update');
|
||||
});
|
||||
|
||||
root.querySelector('#apply-update button').addEventListener('click', () => {
|
||||
ipcRenderer.send('apply-update');
|
||||
});
|
||||
|
||||
ipcRenderer.on('update-checking', () => {
|
||||
updateEl.style.display = 'none';
|
||||
successEl.innerHTML = 'Checking...';
|
||||
successEl.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
});
|
||||
|
||||
ipcRenderer.on('update-available', () => {
|
||||
updateEl.style.display = 'none';
|
||||
successEl.innerHTML = 'Update available! Downloading...';
|
||||
successEl.style.display = 'block';
|
||||
});
|
||||
|
||||
ipcRenderer.on('update-downloaded', () => {
|
||||
updateEl.style.display = 'none';
|
||||
successEl.style.display = 'none';
|
||||
applyUpdateEl.style.display = 'block';
|
||||
});
|
||||
|
||||
ipcRenderer.on('update-not-available', () => {
|
||||
updateEl.style.display = 'none';
|
||||
successEl.innerHTML = 'All up to date!';
|
||||
successEl.style.display = 'block';
|
||||
});
|
||||
|
||||
ipcRenderer.on('update-error', (event, msg) => {
|
||||
updateEl.style.display = 'block';
|
||||
successEl.style.display = 'none';
|
||||
|
||||
let text;
|
||||
if (msg.domain === 'SQRLUpdaterErrorDomain' && msg.code === 8) {
|
||||
text = `
|
||||
Error updating the app. It looks like it’s running outside of the applications
|
||||
folder and can’t be written to. Please install the app before updating.
|
||||
`;
|
||||
} else {
|
||||
text = 'Error updating the app. Please try again later.';
|
||||
}
|
||||
|
||||
errorEl.innerHTML = `<div style="text-align:center; color:#F65151">${text}</div>`;
|
||||
errorEl.style.display = 'block';
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
// Disable zoom with keys + and -
|
||||
if (e.key === '+' || e.key === '-') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import NodeModule from 'module';
|
||||
import Module from 'module';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
@@ -9,29 +9,24 @@ import {
|
||||
Menu,
|
||||
dialog,
|
||||
shell,
|
||||
powerMonitor,
|
||||
protocol,
|
||||
utilityProcess,
|
||||
UtilityProcess,
|
||||
} from 'electron';
|
||||
import isDev from 'electron-is-dev';
|
||||
// @ts-strict-ignore
|
||||
import fetch from 'node-fetch';
|
||||
import promiseRetry from 'promise-retry';
|
||||
|
||||
import about from './about';
|
||||
import { getMenu } from './menu';
|
||||
import updater from './updater';
|
||||
import {
|
||||
get as getWindowState,
|
||||
listen as listenToWindowState,
|
||||
} from './window-state';
|
||||
|
||||
import './setRequireHook';
|
||||
|
||||
import './security';
|
||||
|
||||
const Module: typeof NodeModule & { globalPaths: string[] } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
NodeModule as unknown as any;
|
||||
|
||||
Module.globalPaths.push(__dirname + '/..');
|
||||
|
||||
// This allows relative URLs to be resolved to app:// which makes
|
||||
@@ -40,7 +35,7 @@ protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'app', privileges: { standard: true } },
|
||||
]);
|
||||
|
||||
global.fetch = require('node-fetch');
|
||||
global.fetch = fetch;
|
||||
|
||||
if (!isDev || !process.env.ACTUAL_DOCUMENT_DIR) {
|
||||
process.env.ACTUAL_DOCUMENT_DIR = app.getPath('documents');
|
||||
@@ -55,17 +50,6 @@ if (!isDev || !process.env.ACTUAL_DATA_DIR) {
|
||||
let clientWin: BrowserWindow | null;
|
||||
let serverProcess: UtilityProcess | null;
|
||||
|
||||
updater.onEvent((type: string, data: Record<string, string> | string) => {
|
||||
// Notify both the app and the about window
|
||||
if (clientWin) {
|
||||
clientWin.webContents.send(type, data);
|
||||
}
|
||||
|
||||
if (about.getWindow()) {
|
||||
about.getWindow().webContents.send(type, data);
|
||||
}
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
process.traceProcessWarnings = true;
|
||||
}
|
||||
@@ -82,13 +66,6 @@ function createBackgroundProcess() {
|
||||
case 'captureEvent':
|
||||
case 'captureBreadcrumb':
|
||||
break;
|
||||
case 'shouldAutoUpdate':
|
||||
if (msg.flag) {
|
||||
updater.start();
|
||||
} else {
|
||||
updater.stop();
|
||||
}
|
||||
break;
|
||||
case 'reply':
|
||||
case 'error':
|
||||
case 'push':
|
||||
@@ -265,7 +242,7 @@ app.on('ready', async () => {
|
||||
|
||||
// This is mainly to aid debugging Sentry errors - it will add a
|
||||
// breadcrumb
|
||||
require('electron').powerMonitor.on('suspend', () => {
|
||||
powerMonitor.on('suspend', () => {
|
||||
console.log('Suspending', new Date());
|
||||
});
|
||||
|
||||
@@ -331,10 +308,6 @@ ipcMain.handle('open-external-url', (event, url) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.on('show-about', () => {
|
||||
about.openAboutWindow();
|
||||
});
|
||||
|
||||
ipcMain.on('message', (_event, msg) => {
|
||||
if (!serverProcess) {
|
||||
return;
|
||||
@@ -354,23 +327,6 @@ ipcMain.on('screenshot', () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('check-for-update', () => {
|
||||
// If the updater is in the middle of an update already, send the
|
||||
// about window the current status
|
||||
if (updater.isChecking()) {
|
||||
// This should always come from the about window so we can
|
||||
// guarantee that it exists. If we ever see an error here
|
||||
// something is wrong
|
||||
about.getWindow().webContents.send(updater.getLastEvent());
|
||||
} else {
|
||||
updater.check();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('apply-update', () => {
|
||||
updater.apply();
|
||||
});
|
||||
|
||||
ipcMain.on('update-menu', (event, budgetId?: string) => {
|
||||
updateMenu(budgetId);
|
||||
});
|
||||
|
||||
@@ -182,27 +182,11 @@ export function getMenu(
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Add about to help menu on Windows
|
||||
(
|
||||
template[template.length - 1].submenu as MenuItemConstructorOptions[]
|
||||
).unshift({
|
||||
label: 'About Actual',
|
||||
click() {
|
||||
ipcMain.emit('show-about');
|
||||
},
|
||||
});
|
||||
} else if (process.platform === 'darwin') {
|
||||
if (process.platform === 'darwin') {
|
||||
const name = app.getName();
|
||||
template.unshift({
|
||||
label: name,
|
||||
submenu: [
|
||||
{
|
||||
label: 'About Actual',
|
||||
click() {
|
||||
ipcMain.emit('show-about');
|
||||
},
|
||||
},
|
||||
isDev
|
||||
? {
|
||||
label: 'Screenshot',
|
||||
|
||||
9
packages/desktop-electron/modules.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module 'module' {
|
||||
const globalPaths: string[];
|
||||
}
|
||||
|
||||
// bundles not available until we build them
|
||||
declare module 'loot-core/lib-dist/bundle.desktop.js' {
|
||||
const initApp: (isDev: boolean) => void;
|
||||
const lib: { getDataDir: () => string };
|
||||
}
|
||||
@@ -35,16 +35,23 @@
|
||||
"icon": "icons/icon.icns",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"artifactName": "${productName}-mac.${ext}",
|
||||
"notarize": {
|
||||
"teamId": "79ANZ983YF"
|
||||
}
|
||||
},
|
||||
"target": [
|
||||
{
|
||||
"target": "default",
|
||||
"arch": ["universal"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"flatpak",
|
||||
"AppImage"
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||
"artifactName": "${productName}-linux.${ext}"
|
||||
},
|
||||
"flatpak": {
|
||||
"runtimeVersion": "23.08",
|
||||
@@ -52,13 +59,13 @@
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "icons/icon.ico"
|
||||
"icon": "icons/icon.ico",
|
||||
"artifactName": "${productName}-windows.${ext}"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-is-dev": "2.0.0",
|
||||
"electron-log": "4.4.8",
|
||||
"electron-updater": "6.1.7",
|
||||
"loot-core": "*",
|
||||
"node-fetch": "^2.7.0",
|
||||
"promise-retry": "^2.0.1"
|
||||
|
||||
@@ -45,10 +45,6 @@ contextBridge.exposeInMainWorld('Actual', {
|
||||
ipcRenderer.on(type, handler);
|
||||
},
|
||||
|
||||
applyAppUpdate: () => {
|
||||
ipcRenderer.send('apply-update');
|
||||
},
|
||||
|
||||
updateAppMenu: budgetId => {
|
||||
ipcRenderer.send('update-menu', budgetId);
|
||||
},
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="server.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
require('./setRequireHook');
|
||||
require('module').globalPaths.push(__dirname + '/..');
|
||||
|
||||
global.fetch = require('node-fetch');
|
||||
|
||||
// Lazy load backend code
|
||||
function getBackend() {
|
||||
// eslint-disable-next-line import/extensions
|
||||
return require('loot-core/lib-dist/bundle.desktop.js');
|
||||
}
|
||||
|
||||
const isDev = false;
|
||||
|
||||
// Start the app
|
||||
getBackend().initApp(isDev);
|
||||
18
packages/desktop-electron/server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import Module from 'module';
|
||||
|
||||
// @ts-strict-ignore
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
Module.globalPaths.push(__dirname + '/..');
|
||||
global.fetch = fetch;
|
||||
|
||||
const lazyLoadBackend = async (isDev: boolean) => {
|
||||
// eslint-disable-next-line import/extensions
|
||||
const bundle = await import('loot-core/lib-dist/bundle.desktop.js');
|
||||
bundle.initApp(isDev);
|
||||
};
|
||||
|
||||
const isDev = false;
|
||||
|
||||
// Start the app
|
||||
lazyLoadBackend(isDev);
|
||||
@@ -1,3 +0,0 @@
|
||||
require.extensions['.electron.js'] = function (module, filename) {
|
||||
return require.extensions['.js'](module, filename);
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const {
|
||||
SIGN_TOOL_PATH = 'C:\\Program Files (x86)\\Windows Kits\\10\\bin\\x64\\signtool.exe',
|
||||
TIMESTAMP_SERVER = 'http://timestamp.digicert.com',
|
||||
} = process.env;
|
||||
|
||||
const SITE = 'https://actualbudget.com/';
|
||||
|
||||
const importPfx = (certPath, password) => {
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const command = [
|
||||
['certutil'],
|
||||
['-f'],
|
||||
['-p', `"${password}"`],
|
||||
['-importPfx', 'My', `"${certPath}"`, 'NoRoot'],
|
||||
]
|
||||
.map(sub => sub.join(' '))
|
||||
.join(' ');
|
||||
/* eslint-enable rulesdir/typography */
|
||||
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error('Unable to import certificate');
|
||||
}
|
||||
};
|
||||
|
||||
const signBinary = (path, name) => {
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const command = [
|
||||
[`"${SIGN_TOOL_PATH}"`],
|
||||
['sign'],
|
||||
['/a'],
|
||||
['/s', 'My'],
|
||||
['/sm'],
|
||||
['/t', `"${TIMESTAMP_SERVER}"`],
|
||||
['/d', `"${name}"`],
|
||||
['/du', `"${SITE}"`],
|
||||
[`"${path}"`],
|
||||
]
|
||||
.map(sub => sub.join(' '))
|
||||
.join(' ');
|
||||
/* eslint-enable rulesdir/typography */
|
||||
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.error(`Signing ${path} failed`);
|
||||
}
|
||||
};
|
||||
|
||||
exports.default = ({ path, name, cscInfo: { file, password } = {} }) => {
|
||||
if (!file) return;
|
||||
|
||||
importPfx(file, password);
|
||||
signBinary(path, name, file);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
const isDev = require('electron-is-dev');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
|
||||
// Every 5 minutes
|
||||
const INTERVAL = 1000 * 60 * 5;
|
||||
let updateTimer = null;
|
||||
let isCheckingForUpdates = false;
|
||||
let emitEvent = null;
|
||||
let lastEvent;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
isCheckingForUpdates = true;
|
||||
fireEvent('update-checking');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
fireEvent('update-available');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', info => {
|
||||
fireEvent('update-downloaded', {
|
||||
releaseNotes: info.releaseNotes,
|
||||
releaseName: info.releaseName,
|
||||
version: info.version,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
isCheckingForUpdates = false;
|
||||
fireEvent('update-not-available');
|
||||
});
|
||||
|
||||
autoUpdater.on('error', message => {
|
||||
isCheckingForUpdates = false;
|
||||
// This is a common error, so don't report it. All sorts of reasons
|
||||
// why this can user that isn't our fault.
|
||||
console.log('There was a problem updating the application: ' + message);
|
||||
fireEvent('update-error', message);
|
||||
});
|
||||
|
||||
function fireEvent(type, args) {
|
||||
emitEvent?.(type, args);
|
||||
lastEvent = type;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (updateTimer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isDev) {
|
||||
console.log('Starting autoupdate check...');
|
||||
|
||||
updateTimer = setInterval(() => {
|
||||
if (!isCheckingForUpdates) {
|
||||
autoUpdater.checkForUpdates().catch(() => {
|
||||
// Do nothing with the error (make sure it's not logged to sentry)
|
||||
});
|
||||
}
|
||||
}, INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function onEvent(handler) {
|
||||
emitEvent = handler;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
console.log('Stopping autoupdate check...');
|
||||
|
||||
clearInterval(updateTimer);
|
||||
updateTimer = null;
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (!isDev && !isCheckingForUpdates) {
|
||||
autoUpdater.checkForUpdates().catch(() => {
|
||||
// Do nothing with the error (make sure it's not logged to sentry)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isChecking() {
|
||||
return isCheckingForUpdates;
|
||||
}
|
||||
|
||||
function getLastEvent() {
|
||||
return lastEvent;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
stop,
|
||||
onEvent,
|
||||
apply,
|
||||
check,
|
||||
isChecking,
|
||||
getLastEvent,
|
||||
};
|
||||
@@ -1,13 +1,22 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const electron = require('electron');
|
||||
import electron, { BrowserWindow } from 'electron';
|
||||
|
||||
// eslint-disable-next-line import/extensions
|
||||
const backend = require('loot-core/lib-dist/bundle.desktop.js');
|
||||
const backend = undefined;
|
||||
const getBackend = async () =>
|
||||
// eslint-disable-next-line import/extensions
|
||||
backend || (await import('loot-core/lib-dist/bundle.desktop.js'));
|
||||
|
||||
function loadState() {
|
||||
let state = {};
|
||||
type WindowState = Electron.Rectangle & {
|
||||
isMaximized?: boolean;
|
||||
isFullScreen?: boolean;
|
||||
displayBounds?: Electron.Rectangle;
|
||||
};
|
||||
|
||||
async function loadState() {
|
||||
let state: WindowState | undefined = undefined;
|
||||
const backend = await getBackend();
|
||||
try {
|
||||
state = JSON.parse(
|
||||
fs.readFileSync(
|
||||
@@ -18,24 +27,27 @@ function loadState() {
|
||||
} catch (e) {
|
||||
console.log('Could not load window state');
|
||||
}
|
||||
|
||||
return validateState(state);
|
||||
}
|
||||
|
||||
function updateState(win, state) {
|
||||
const screen = electron.screen || electron.remote.screen;
|
||||
function updateState(win: BrowserWindow, state: WindowState) {
|
||||
const screen = electron.screen;
|
||||
const bounds = win.getBounds();
|
||||
if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) {
|
||||
state.x = bounds.x;
|
||||
state.y = bounds.y;
|
||||
state.width = bounds.width;
|
||||
state.height = bounds.height;
|
||||
}
|
||||
|
||||
state.x = bounds.x;
|
||||
state.y = bounds.y;
|
||||
state.isMaximized = win.isMaximized();
|
||||
state.isFullScreen = win.isFullScreen();
|
||||
state.displayBounds = screen.getDisplayMatching(bounds).bounds;
|
||||
}
|
||||
|
||||
function saveState(win, state) {
|
||||
async function saveState(win: BrowserWindow, state: WindowState) {
|
||||
const backend = await getBackend();
|
||||
updateState(win, state);
|
||||
fs.writeFileSync(
|
||||
path.join(backend.lib.getDataDir(), 'window.json'),
|
||||
@@ -44,7 +56,7 @@ function saveState(win, state) {
|
||||
);
|
||||
}
|
||||
|
||||
function listen(win, state) {
|
||||
export function listen(win: BrowserWindow, state: WindowState) {
|
||||
if (state.isMaximized) {
|
||||
win.maximize();
|
||||
}
|
||||
@@ -61,7 +73,7 @@ function listen(win, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function hasBounds(state) {
|
||||
function hasBounds(state: WindowState) {
|
||||
return (
|
||||
Number.isInteger(state.x) &&
|
||||
Number.isInteger(state.y) &&
|
||||
@@ -72,15 +84,18 @@ function hasBounds(state) {
|
||||
);
|
||||
}
|
||||
|
||||
function validateState(state) {
|
||||
if (!(hasBounds(state) || state.isMaximized || state.isFullScreen)) {
|
||||
function validateState(state?: WindowState): Partial<WindowState> {
|
||||
if (
|
||||
!state ||
|
||||
!(hasBounds(state) || state.isMaximized || state.isFullScreen)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const newState = Object.assign({}, state);
|
||||
|
||||
if (hasBounds(state) && state.displayBounds) {
|
||||
const screen = electron.screen || electron.remote.screen;
|
||||
const screen = electron.screen;
|
||||
|
||||
// Check if the display where the window was last open is still available
|
||||
const displayBounds = screen.getDisplayMatching(state).bounds;
|
||||
@@ -116,22 +131,19 @@ function validateState(state) {
|
||||
return newState;
|
||||
}
|
||||
|
||||
async function get() {
|
||||
const screen = electron.screen || electron.remote.screen;
|
||||
export async function get() {
|
||||
const screen = electron.screen;
|
||||
const displayBounds = screen.getPrimaryDisplay().bounds;
|
||||
|
||||
let state = loadState();
|
||||
state = Object.assign(
|
||||
const state: WindowState = Object.assign(
|
||||
{
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: Math.min(1000, displayBounds.width - 100),
|
||||
height: Math.min(700, displayBounds.width - 50),
|
||||
},
|
||||
state,
|
||||
await loadState(),
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
module.exports = { get, listen };
|
||||
@@ -0,0 +1,19 @@
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
UPDATE transactions AS t1
|
||||
SET schedule = (
|
||||
SELECT t2.schedule FROM transactions AS t2
|
||||
WHERE t2.id = t1.transferred_id
|
||||
AND t2.schedule IS NOT NULL
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE t1.schedule IS NULL
|
||||
AND t1.transferred_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM transactions AS t2
|
||||
WHERE t2.id = t1.transferred_id
|
||||
AND t2.schedule IS NOT NULL
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -95,6 +95,11 @@ export function syncAccounts(id?: string) {
|
||||
.queries.accounts.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
|
||||
dispatch(setAccountsSyncing(accountIdsToSync));
|
||||
@@ -185,6 +190,27 @@ export function parseTransactions(filepath, options) {
|
||||
};
|
||||
}
|
||||
|
||||
export function importPreviewTransactions(id: string, transactions) {
|
||||
return async (dispatch: Dispatch): Promise<boolean> => {
|
||||
const { errors = [], updatedPreview } = await send('transactions-import', {
|
||||
accountId: id,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
};
|
||||
}
|
||||
|
||||
export function importTransactions(id: string, transactions, reconcile = true) {
|
||||
return async (dispatch: Dispatch): Promise<boolean> => {
|
||||
if (!reconcile) {
|
||||
@@ -203,6 +229,7 @@ export function importTransactions(id: string, transactions, reconcile = true) {
|
||||
} = await send('transactions-import', {
|
||||
accountId: id,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { createContext, useEffect, useState, useContext } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { q, type Query } from '../../shared/query';
|
||||
import { getStatus, getHasTransactionsQuery } from '../../shared/schedules';
|
||||
import { type ScheduleEntity } from '../../types/models';
|
||||
import { getAccountFilter } from '../queries';
|
||||
import { liveQuery } from '../query-helpers';
|
||||
|
||||
export type ScheduleStatusType = ReturnType<typeof getStatus>;
|
||||
@@ -84,3 +91,26 @@ export function SchedulesProvider({ transform, children }) {
|
||||
export function useCachedSchedules() {
|
||||
return useContext(SchedulesContext);
|
||||
}
|
||||
|
||||
export function useDefaultSchedulesQueryTransform(accountId) {
|
||||
return useMemo(() => {
|
||||
const filterByAccount = getAccountFilter(accountId, '_account');
|
||||
const filterByPayee = getAccountFilter(accountId, '_payee.transfer_acct');
|
||||
|
||||
return (q: Query) => {
|
||||
q = q.filter({
|
||||
$and: [{ '_account.closed': false }],
|
||||
});
|
||||
if (accountId) {
|
||||
if (accountId === 'uncategorized') {
|
||||
q = q.filter({ next_date: null });
|
||||
} else {
|
||||
q = q.filter({
|
||||
$or: [filterByAccount, filterByPayee],
|
||||
});
|
||||
}
|
||||
}
|
||||
return q.orderBy({ next_date: 'desc' });
|
||||
};
|
||||
}, [accountId]);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ function reconcileFiles(
|
||||
groupId,
|
||||
deleted: false,
|
||||
state: 'unknown',
|
||||
hasKey: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,10 +86,11 @@ function reconcileFiles(
|
||||
groupId,
|
||||
deleted: false,
|
||||
state: 'broken',
|
||||
hasKey: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { ...localFile, deleted: false, state: 'local' };
|
||||
return { ...localFile, deleted: false, state: 'local', hasKey: true };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ const initialState: ModalsState = {
|
||||
export function update(state = initialState, action: Action): ModalsState {
|
||||
switch (action.type) {
|
||||
case constants.PUSH_MODAL:
|
||||
// special case: don't show the keyboard shortcuts modal if there's already a modal open
|
||||
if (
|
||||
action.modal.name.endsWith('keyboard-shortcuts') &&
|
||||
(state.modalStack.length > 0 ||
|
||||
window.document.querySelector(
|
||||
'div[data-testid="filters-menu-tooltip"]',
|
||||
) !== null)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
modalStack: [...state.modalStack, action.modal],
|
||||
|
||||
@@ -129,7 +129,6 @@ type FinanceModals = {
|
||||
'schedules-discover': null;
|
||||
|
||||
'schedule-posts-offline-notification': null;
|
||||
'switch-budget-type': { onSwitch: () => void };
|
||||
'account-menu': {
|
||||
accountId: string;
|
||||
onSave: (account: AccountEntity) => void;
|
||||
@@ -237,7 +236,6 @@ type FinanceModals = {
|
||||
onAddCategoryGroup: () => void;
|
||||
onToggleHiddenCategories: () => void;
|
||||
onSwitchBudgetFile: () => void;
|
||||
onSwitchBudgetType: () => void;
|
||||
};
|
||||
'rollover-budget-month-menu': {
|
||||
month: string;
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
makeChild as makeChildTransaction,
|
||||
recalculateSplit,
|
||||
} from '../../shared/transactions';
|
||||
import { hasFieldsChanged, amountToInteger } from '../../shared/util';
|
||||
import {
|
||||
hasFieldsChanged,
|
||||
amountToInteger,
|
||||
integerToAmount,
|
||||
} from '../../shared/util';
|
||||
import * as db from '../db';
|
||||
import { runMutator } from '../mutators';
|
||||
import { post } from '../post';
|
||||
@@ -251,55 +255,11 @@ async function normalizeBankSyncTransactions(transactions, acctId) {
|
||||
throw new Error('`date` is required when adding a transaction');
|
||||
}
|
||||
|
||||
let payee_name;
|
||||
// When the amount is equal to 0, we need to determine
|
||||
// if this is a "Credited" or "Debited" transaction. This means
|
||||
// that it matters whether the amount is a positive or negative zero.
|
||||
if (trans.amount > 0 || Object.is(Number(trans.amount), 0)) {
|
||||
const nameParts = [];
|
||||
const name =
|
||||
trans.debtorName ||
|
||||
trans.remittanceInformationUnstructured ||
|
||||
(trans.remittanceInformationUnstructuredArray || []).join(', ') ||
|
||||
trans.additionalInformation;
|
||||
|
||||
if (name) {
|
||||
nameParts.push(title(name));
|
||||
}
|
||||
if (trans.debtorAccount && trans.debtorAccount.iban) {
|
||||
nameParts.push(
|
||||
'(' +
|
||||
trans.debtorAccount.iban.slice(0, 4) +
|
||||
' XXX ' +
|
||||
trans.debtorAccount.iban.slice(-4) +
|
||||
')',
|
||||
);
|
||||
}
|
||||
payee_name = nameParts.join(' ');
|
||||
} else {
|
||||
const nameParts = [];
|
||||
const name =
|
||||
trans.creditorName ||
|
||||
trans.remittanceInformationUnstructured ||
|
||||
(trans.remittanceInformationUnstructuredArray || []).join(', ') ||
|
||||
trans.additionalInformation;
|
||||
|
||||
if (name) {
|
||||
nameParts.push(title(name));
|
||||
}
|
||||
if (trans.creditorAccount && trans.creditorAccount.iban) {
|
||||
nameParts.push(
|
||||
'(' +
|
||||
trans.creditorAccount.iban.slice(0, 4) +
|
||||
' XXX ' +
|
||||
trans.creditorAccount.iban.slice(-4) +
|
||||
')',
|
||||
);
|
||||
}
|
||||
payee_name = nameParts.join(' ');
|
||||
if (trans.payeeName == null) {
|
||||
throw new Error('`payeeName` is required when adding a transaction');
|
||||
}
|
||||
|
||||
trans.imported_payee = trans.imported_payee || payee_name;
|
||||
trans.imported_payee = trans.imported_payee || trans.payeeName;
|
||||
if (trans.imported_payee) {
|
||||
trans.imported_payee = trans.imported_payee.trim();
|
||||
}
|
||||
@@ -308,12 +268,12 @@ async function normalizeBankSyncTransactions(transactions, acctId) {
|
||||
// when rules are run, they have the right data. Resolving payees
|
||||
// also simplifies the payee creation process
|
||||
trans.account = acctId;
|
||||
trans.payee = await resolvePayee(trans, payee_name, payeesToCreate);
|
||||
trans.payee = await resolvePayee(trans, trans.payeeName, payeesToCreate);
|
||||
|
||||
trans.cleared = Boolean(trans.booked);
|
||||
|
||||
normalized.push({
|
||||
payee_name,
|
||||
payee_name: trans.payeeName,
|
||||
trans: {
|
||||
amount: amountToInteger(trans.amount),
|
||||
payee: trans.payee,
|
||||
@@ -349,12 +309,117 @@ export async function reconcileTransactions(
|
||||
acctId,
|
||||
transactions,
|
||||
isBankSyncAccount = false,
|
||||
isPreview = false,
|
||||
) {
|
||||
console.log('Performing transaction reconciliation');
|
||||
|
||||
const hasMatched = new Set();
|
||||
const updated = [];
|
||||
const added = [];
|
||||
const updatedPreview = [];
|
||||
const existingPayeeMap = new Map<string, string>();
|
||||
|
||||
const {
|
||||
payeesToCreate,
|
||||
transactionsStep1,
|
||||
transactionsStep2,
|
||||
transactionsStep3,
|
||||
} = await matchTransactions(acctId, transactions, isBankSyncAccount);
|
||||
|
||||
// Finally, generate & commit the changes
|
||||
for (const { trans, subtransactions, match } of transactionsStep3) {
|
||||
if (match && !trans.forceAddTransaction) {
|
||||
// Skip updating already reconciled (locked) transactions
|
||||
if (match.reconciled) {
|
||||
updatedPreview.push({ transaction: trans, ignored: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: change the above sql query to use aql
|
||||
const existing = {
|
||||
...match,
|
||||
cleared: match.cleared === 1,
|
||||
date: db.fromDateRepr(match.date),
|
||||
};
|
||||
|
||||
// Update the transaction
|
||||
const updates = {
|
||||
imported_id: trans.imported_id || null,
|
||||
payee: existing.payee || trans.payee || null,
|
||||
category: existing.category || trans.category || null,
|
||||
imported_payee: trans.imported_payee || null,
|
||||
notes: existing.notes || trans.notes || null,
|
||||
cleared: trans.cleared != null ? trans.cleared : true,
|
||||
};
|
||||
|
||||
if (hasFieldsChanged(existing, updates, Object.keys(updates))) {
|
||||
updated.push({ id: existing.id, ...updates });
|
||||
if (!existingPayeeMap.has(existing.payee)) {
|
||||
const payee = await db.getPayee(existing.payee);
|
||||
existingPayeeMap.set(existing.payee, payee?.name);
|
||||
}
|
||||
existing.payee_name = existingPayeeMap.get(existing.payee);
|
||||
existing.amount = integerToAmount(existing.amount);
|
||||
updatedPreview.push({ transaction: trans, existing });
|
||||
} else {
|
||||
updatedPreview.push({ transaction: trans, ignored: true });
|
||||
}
|
||||
|
||||
if (existing.is_parent && existing.cleared !== updates.cleared) {
|
||||
const children = await db.all(
|
||||
'SELECT id FROM v_transactions WHERE parent_id = ?',
|
||||
[existing.id],
|
||||
);
|
||||
for (const child of children) {
|
||||
updated.push({ id: child.id, cleared: updates.cleared });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Insert a new transaction
|
||||
const { forceAddTransaction, ...newTrans } = trans;
|
||||
const finalTransaction = {
|
||||
...newTrans,
|
||||
id: uuidv4(),
|
||||
category: trans.category || null,
|
||||
cleared: trans.cleared != null ? trans.cleared : true,
|
||||
};
|
||||
|
||||
if (subtransactions && subtransactions.length > 0) {
|
||||
added.push(...makeSplitTransaction(finalTransaction, subtransactions));
|
||||
} else {
|
||||
added.push(finalTransaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPreview) {
|
||||
await createNewPayees(payeesToCreate, [...added, ...updated]);
|
||||
await batchUpdateTransactions({ added, updated });
|
||||
}
|
||||
|
||||
console.log('Debug data for the operations:', {
|
||||
transactionsStep1,
|
||||
transactionsStep2,
|
||||
transactionsStep3,
|
||||
added,
|
||||
updated,
|
||||
updatedPreview,
|
||||
});
|
||||
|
||||
return {
|
||||
added: added.map(trans => trans.id),
|
||||
updated: updated.map(trans => trans.id),
|
||||
updatedPreview,
|
||||
};
|
||||
}
|
||||
|
||||
export async function matchTransactions(
|
||||
acctId,
|
||||
transactions,
|
||||
isBankSyncAccount = false,
|
||||
) {
|
||||
console.log('Performing transaction reconciliation matching');
|
||||
|
||||
const hasMatched = new Set();
|
||||
|
||||
const transactionNormalization = isBankSyncAccount
|
||||
? normalizeBankSyncTransactions
|
||||
@@ -399,7 +464,7 @@ export async function reconcileTransactions(
|
||||
// matched transaction. See the final pass below for the needed
|
||||
// fields.
|
||||
fuzzyDataset = await db.all(
|
||||
`SELECT id, is_parent, date, imported_id, payee, category, notes, reconciled FROM v_transactions
|
||||
`SELECT id, is_parent, date, imported_id, payee, imported_payee, category, notes, reconciled, cleared, amount FROM v_transactions
|
||||
WHERE date >= ? AND date <= ? AND amount = ? AND account = ?`,
|
||||
[
|
||||
db.toDateRepr(monthUtils.subDays(trans.date, 7)),
|
||||
@@ -474,75 +539,11 @@ export async function reconcileTransactions(
|
||||
return data;
|
||||
});
|
||||
|
||||
// Finally, generate & commit the changes
|
||||
for (const { trans, subtransactions, match } of transactionsStep3) {
|
||||
if (match) {
|
||||
// Skip updating already reconciled (locked) transactions
|
||||
if (match.reconciled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: change the above sql query to use aql
|
||||
const existing = {
|
||||
...match,
|
||||
cleared: match.cleared === 1,
|
||||
date: db.fromDateRepr(match.date),
|
||||
};
|
||||
|
||||
// Update the transaction
|
||||
const updates = {
|
||||
imported_id: trans.imported_id || null,
|
||||
payee: existing.payee || trans.payee || null,
|
||||
category: existing.category || trans.category || null,
|
||||
imported_payee: trans.imported_payee || null,
|
||||
notes: existing.notes || trans.notes || null,
|
||||
cleared: trans.cleared != null ? trans.cleared : true,
|
||||
};
|
||||
|
||||
if (hasFieldsChanged(existing, updates, Object.keys(updates))) {
|
||||
updated.push({ id: existing.id, ...updates });
|
||||
}
|
||||
|
||||
if (existing.is_parent && existing.cleared !== updates.cleared) {
|
||||
const children = await db.all(
|
||||
'SELECT id FROM v_transactions WHERE parent_id = ?',
|
||||
[existing.id],
|
||||
);
|
||||
for (const child of children) {
|
||||
updated.push({ id: child.id, cleared: updates.cleared });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Insert a new transaction
|
||||
const finalTransaction = {
|
||||
...trans,
|
||||
id: uuidv4(),
|
||||
category: trans.category || null,
|
||||
cleared: trans.cleared != null ? trans.cleared : true,
|
||||
};
|
||||
|
||||
if (subtransactions && subtransactions.length > 0) {
|
||||
added.push(...makeSplitTransaction(finalTransaction, subtransactions));
|
||||
} else {
|
||||
added.push(finalTransaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await createNewPayees(payeesToCreate, [...added, ...updated]);
|
||||
await batchUpdateTransactions({ added, updated });
|
||||
|
||||
console.log('Debug data for the operations:', {
|
||||
return {
|
||||
payeesToCreate,
|
||||
transactionsStep1,
|
||||
transactionsStep2,
|
||||
transactionsStep3,
|
||||
added,
|
||||
updated,
|
||||
});
|
||||
|
||||
return {
|
||||
added: added.map(trans => trans.id),
|
||||
updated: updated.map(trans => trans.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function addTransfer(transaction, transferredAccount) {
|
||||
date: transaction.date,
|
||||
transfer_id: transaction.id,
|
||||
notes: transaction.notes || null,
|
||||
schedule: transaction.schedule,
|
||||
cleared: false,
|
||||
});
|
||||
|
||||
@@ -130,6 +131,7 @@ export async function updateTransfer(transaction, transferredAccount) {
|
||||
date: transaction.date,
|
||||
notes: transaction.notes,
|
||||
amount: -transaction.amount,
|
||||
schedule: transaction.schedule,
|
||||
});
|
||||
|
||||
const categoryCleared = await clearCategory(transaction, transferredAccount);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Budget } from '../types/budget';
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
@@ -5,6 +6,7 @@ import type {
|
||||
PayeeEntity,
|
||||
} from '../types/models';
|
||||
|
||||
import { RemoteFile } from './cloud-storage';
|
||||
import * as models from './models';
|
||||
|
||||
export type APIAccountEntity = Pick<AccountEntity, 'id' | 'name'> & {
|
||||
@@ -114,3 +116,39 @@ export const payeeModel = {
|
||||
return payee as PayeeEntity;
|
||||
},
|
||||
};
|
||||
|
||||
export type APIFileEntity = Omit<RemoteFile, 'deleted' | 'fileId'> & {
|
||||
id?: string;
|
||||
cloudFileId: string;
|
||||
state?: 'remote';
|
||||
};
|
||||
|
||||
export const remoteFileModel = {
|
||||
toExternal(file: RemoteFile): APIFileEntity | null {
|
||||
if (file.deleted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
cloudFileId: file.fileId,
|
||||
state: 'remote',
|
||||
groupId: file.groupId,
|
||||
name: file.name,
|
||||
encryptKeyId: file.encryptKeyId,
|
||||
hasKey: file.hasKey,
|
||||
};
|
||||
},
|
||||
|
||||
fromExternal(file: APIFileEntity) {
|
||||
return { deleted: false, fileId: file.cloudFileId, ...file } as RemoteFile;
|
||||
},
|
||||
};
|
||||
|
||||
export const budgetModel = {
|
||||
toExternal(file: Budget): APIFileEntity {
|
||||
return file as APIFileEntity;
|
||||
},
|
||||
|
||||
fromExternal(file: APIFileEntity) {
|
||||
return file as Budget;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,9 +22,11 @@ import { ServerHandlers } from '../types/server-handlers';
|
||||
import { addTransactions } from './accounts/sync';
|
||||
import {
|
||||
accountModel,
|
||||
budgetModel,
|
||||
categoryModel,
|
||||
categoryGroupModel,
|
||||
payeeModel,
|
||||
remoteFileModel,
|
||||
} from './api-models';
|
||||
import { runQuery as aqlQuery } from './aql';
|
||||
import * as cloudStorage from './cloud-storage';
|
||||
@@ -226,6 +228,15 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
|
||||
await handlers['load-budget']({ id: result.id });
|
||||
};
|
||||
|
||||
handlers['api/get-budgets'] = async function () {
|
||||
const budgets = await handlers['get-budgets']();
|
||||
const files = (await handlers['get-remote-files']()) || [];
|
||||
return [
|
||||
...budgets.map(file => budgetModel.toExternal(file)),
|
||||
...files.map(file => remoteFileModel.toExternal(file)).filter(file => file),
|
||||
];
|
||||
};
|
||||
|
||||
handlers['api/sync'] = async function () {
|
||||
const { id } = prefs.getPrefs();
|
||||
const result = await handlers['sync-budget']();
|
||||
@@ -411,9 +422,14 @@ handlers['api/transactions-export'] = async function ({
|
||||
handlers['api/transactions-import'] = withMutation(async function ({
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview = false,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return handlers['transactions-import']({ accountId, transactions });
|
||||
return handlers['transactions-import']({
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview,
|
||||
});
|
||||
});
|
||||
|
||||
handlers['api/transactions-add'] = withMutation(async function ({
|
||||
@@ -533,6 +549,14 @@ handlers['api/account-delete'] = withMutation(async function ({ id }) {
|
||||
return handlers['account-close']({ id, forced: true });
|
||||
});
|
||||
|
||||
handlers['api/account-balance'] = withMutation(async function ({
|
||||
id,
|
||||
cutoff = new Date(),
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return handlers['account-balance']({ id, cutoff });
|
||||
});
|
||||
|
||||
handlers['api/categories-get'] = async function ({
|
||||
grouped,
|
||||
}: { grouped? } = {}) {
|
||||
@@ -629,6 +653,14 @@ handlers['api/payee-delete'] = withMutation(async function ({ id }) {
|
||||
return handlers['payees-batch-change']({ deleted: [{ id }] });
|
||||
});
|
||||
|
||||
handlers['api/payees-merge'] = withMutation(async function ({
|
||||
targetId,
|
||||
mergeIds,
|
||||
}) {
|
||||
checkFileOpen();
|
||||
return handlers['payees-merge']({ targetId, mergeIds });
|
||||
});
|
||||
|
||||
handlers['api/rules-get'] = async function () {
|
||||
checkFileOpen();
|
||||
return handlers['rules-get']();
|
||||
|
||||
@@ -69,6 +69,8 @@ export const schema = {
|
||||
closed: f('boolean'),
|
||||
sort_order: f('float'),
|
||||
tombstone: f('boolean'),
|
||||
account_id: f('string'),
|
||||
official_name: f('string'),
|
||||
account_sync_source: f('string'),
|
||||
},
|
||||
categories: {
|
||||
|
||||
@@ -19,14 +19,12 @@ import { goalsWeek } from './goals/goalsWeek';
|
||||
export async function applyTemplate({ month }) {
|
||||
await storeTemplates();
|
||||
const category_templates = await getTemplates(null);
|
||||
await resetCategoryTargets({ month, category: null });
|
||||
return processTemplate(month, false, category_templates);
|
||||
}
|
||||
|
||||
export async function overwriteTemplate({ month }) {
|
||||
await storeTemplates();
|
||||
const category_templates = await getTemplates(null);
|
||||
await resetCategoryTargets({ month, category: null });
|
||||
return processTemplate(month, true, category_templates);
|
||||
}
|
||||
|
||||
@@ -36,8 +34,7 @@ export async function applySingleCategoryTemplate({ month, category }) {
|
||||
]);
|
||||
await storeTemplates();
|
||||
const category_templates = await getTemplates(categories[0]);
|
||||
await resetCategoryTargets({ month, category: categories });
|
||||
return processTemplate(month, true, category_templates);
|
||||
return processTemplate(month, true, category_templates, categories[0]);
|
||||
}
|
||||
|
||||
export function runCheckTemplates() {
|
||||
@@ -91,21 +88,21 @@ async function setCategoryTargets({ month, idealTemplate }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function resetCategoryTargets({ month, category }) {
|
||||
let categories;
|
||||
async function resetCategoryTargets(month, category) {
|
||||
let categories = [];
|
||||
if (category === null) {
|
||||
categories = await getCategories();
|
||||
} else {
|
||||
categories = category;
|
||||
}
|
||||
await batchMessages(async () => {
|
||||
categories.forEach(element => {
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
setGoal({
|
||||
category: element.id,
|
||||
category: categories[i].id,
|
||||
goal: null,
|
||||
month,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +152,7 @@ async function processTemplate(
|
||||
month,
|
||||
force,
|
||||
category_templates,
|
||||
category?,
|
||||
): Promise<Notification> {
|
||||
let num_applied = 0;
|
||||
let errors = [];
|
||||
@@ -162,8 +160,13 @@ async function processTemplate(
|
||||
const setToZero = [];
|
||||
let priority_list = [];
|
||||
|
||||
const categories = await getCategories();
|
||||
let categories = [];
|
||||
const categories_remove = [];
|
||||
if (category) {
|
||||
categories[0] = category;
|
||||
} else {
|
||||
categories = await getCategories();
|
||||
}
|
||||
|
||||
//clears templated categories
|
||||
for (let c = 0; c < categories.length; c++) {
|
||||
@@ -205,11 +208,12 @@ async function processTemplate(
|
||||
categories.splice(categories_remove[i], 1);
|
||||
}
|
||||
|
||||
// zero out the categories that need it
|
||||
// zero out budget and goal from categories that need it
|
||||
await setGoalBudget({
|
||||
month,
|
||||
templateBudget: setToZero.filter(f => f.isTemplate === true),
|
||||
});
|
||||
await resetCategoryTargets(month, categories);
|
||||
|
||||
// sort and filter down to just the requested priorities
|
||||
priority_list = priority_list
|
||||
|
||||
@@ -13,6 +13,7 @@ import { logger } from '../platform/server/log';
|
||||
import * as sqlite from '../platform/server/sqlite';
|
||||
import { isNonProductionEnvironment } from '../shared/environment';
|
||||
import * as monthUtils from '../shared/months';
|
||||
import { dayFromDate } from '../shared/months';
|
||||
import { q, Query } from '../shared/query';
|
||||
import { amountToInteger, stringToInteger } from '../shared/util';
|
||||
import { type Budget } from '../types/budget';
|
||||
@@ -578,6 +579,14 @@ handlers['accounts-get'] = async function () {
|
||||
return db.getAccounts();
|
||||
};
|
||||
|
||||
handlers['account-balance'] = async function ({ id, cutoff }) {
|
||||
const { balance } = await db.first(
|
||||
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?',
|
||||
[id, db.toDateRepr(dayFromDate(cutoff))],
|
||||
);
|
||||
return balance ? balance : 0;
|
||||
};
|
||||
|
||||
handlers['account-properties'] = async function ({ id }) {
|
||||
const { balance } = await db.first(
|
||||
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
|
||||
@@ -1053,7 +1062,8 @@ handlers['accounts-bank-sync'] = async function ({ id }) {
|
||||
const accounts = await db.runQuery(
|
||||
`SELECT a.*, b.bank_id as bankId FROM accounts a
|
||||
LEFT JOIN banks b ON a.bank = b.id
|
||||
WHERE a.tombstone = 0 AND a.closed = 0 ${id ? 'AND a.id = ?' : ''}`,
|
||||
WHERE a.tombstone = 0 AND a.closed = 0 ${id ? 'AND a.id = ?' : ''}
|
||||
ORDER BY a.offbudget, a.sort_order`,
|
||||
id ? [id] : [],
|
||||
true,
|
||||
);
|
||||
@@ -1131,6 +1141,7 @@ handlers['accounts-bank-sync'] = async function ({ id }) {
|
||||
handlers['transactions-import'] = mutator(function ({
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview,
|
||||
}) {
|
||||
return withUndo(async () => {
|
||||
if (typeof accountId !== 'string') {
|
||||
@@ -1138,10 +1149,20 @@ handlers['transactions-import'] = mutator(function ({
|
||||
}
|
||||
|
||||
try {
|
||||
return await bankSync.reconcileTransactions(accountId, transactions);
|
||||
return await bankSync.reconcileTransactions(
|
||||
accountId,
|
||||
transactions,
|
||||
false,
|
||||
isPreview,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TransactionError) {
|
||||
return { errors: [{ message: err.message }], added: [], updated: [] };
|
||||
return {
|
||||
errors: [{ message: err.message }],
|
||||
added: [],
|
||||
updated: [],
|
||||
updatedPreview: [],
|
||||
};
|
||||
}
|
||||
|
||||
throw err;
|
||||
@@ -1216,13 +1237,6 @@ handlers['save-global-prefs'] = async function (prefs) {
|
||||
if ('maxMonths' in prefs) {
|
||||
await asyncStorage.setItem('max-months', '' + prefs.maxMonths);
|
||||
}
|
||||
if ('autoUpdate' in prefs) {
|
||||
await asyncStorage.setItem('auto-update', '' + prefs.autoUpdate);
|
||||
process.parentPort.postMessage({
|
||||
type: 'shouldAutoUpdate',
|
||||
flag: prefs.autoUpdate,
|
||||
});
|
||||
}
|
||||
if ('documentDir' in prefs) {
|
||||
if (await fs.exists(prefs.documentDir)) {
|
||||
await asyncStorage.setItem('document-dir', prefs.documentDir);
|
||||
@@ -1241,14 +1255,12 @@ handlers['load-global-prefs'] = async function () {
|
||||
const [
|
||||
[, floatingSidebar],
|
||||
[, maxMonths],
|
||||
[, autoUpdate],
|
||||
[, documentDir],
|
||||
[, encryptKey],
|
||||
[, theme],
|
||||
] = await asyncStorage.multiGet([
|
||||
'floating-sidebar',
|
||||
'max-months',
|
||||
'auto-update',
|
||||
'document-dir',
|
||||
'encrypt-key',
|
||||
'theme',
|
||||
@@ -1256,7 +1268,6 @@ handlers['load-global-prefs'] = async function () {
|
||||
return {
|
||||
floatingSidebar: floatingSidebar === 'true' ? true : false,
|
||||
maxMonths: stringToInteger(maxMonths || ''),
|
||||
autoUpdate: autoUpdate == null || autoUpdate === 'true' ? true : false,
|
||||
documentDir: documentDir || getDefaultDocumentDir(),
|
||||
keyId: encryptKey && JSON.parse(encryptKey).id,
|
||||
theme:
|
||||
@@ -2118,14 +2129,6 @@ export async function initApp(isDev, socketName) {
|
||||
|
||||
connection.init(socketName, app.handlers);
|
||||
|
||||
if (!isDev && !Platform.isMobile && !Platform.isWeb) {
|
||||
const autoUpdate = await asyncStorage.getItem('auto-update');
|
||||
process.parentPort.postMessage({
|
||||
type: 'shouldAutoUpdate',
|
||||
flag: autoUpdate == null || autoUpdate === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
// Allow running DB queries locally
|
||||
global.$query = aqlQuery;
|
||||
global.$q = q;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { evalArithmetic } from './arithmetic';
|
||||
import { setNumberFormat } from './util';
|
||||
|
||||
describe('arithmetic', () => {
|
||||
test('handles negative numbers', () => {
|
||||
@@ -40,5 +41,11 @@ describe('arithmetic', () => {
|
||||
|
||||
test('respects current number format', () => {
|
||||
expect(evalArithmetic('1,222.45')).toEqual(1222.45);
|
||||
|
||||
setNumberFormat({ format: 'space-comma', hideFraction: false });
|
||||
expect(evalArithmetic('1\xa0222,45')).toEqual(1222.45);
|
||||
|
||||
setNumberFormat({ format: 'apostrophe-dot', hideFraction: false });
|
||||
expect(evalArithmetic('1’222.45')).toEqual(1222.45);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ function parsePrimary(state) {
|
||||
}
|
||||
|
||||
let numberStr = '';
|
||||
while (char(state) && char(state).match(/[0-9,. ]|\p{Sc}/u)) {
|
||||
while (char(state) && char(state).match(/[0-9,.’\xa0 ]|\p{Sc}/u)) {
|
||||
numberStr += next(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -85,14 +85,13 @@ describe('utility functions', () => {
|
||||
expect(formatter.format(Number('1234.56'))).toBe('1\xa0235');
|
||||
});
|
||||
|
||||
test('number formatting works with space-dot format', () => {
|
||||
setNumberFormat({ format: 'space-dot', hideFraction: false });
|
||||
test('number formatting works with apostrophe-dot format', () => {
|
||||
setNumberFormat({ format: 'apostrophe-dot', hideFraction: false });
|
||||
let formatter = getNumberFormat().formatter;
|
||||
// grouping separator space char is a non-breaking space, or UTF-16 \xa0
|
||||
expect(formatter.format(Number('1234.56'))).toBe('1\xa0234.56');
|
||||
expect(formatter.format(Number('1234.56'))).toBe('1’234.56');
|
||||
|
||||
setNumberFormat({ format: 'space-dot', hideFraction: true });
|
||||
setNumberFormat({ format: 'apostrophe-dot', hideFraction: true });
|
||||
formatter = getNumberFormat().formatter;
|
||||
expect(formatter.format(Number('1234.56'))).toBe('1\xa0235');
|
||||
expect(formatter.format(Number('1234.56'))).toBe('1’235');
|
||||
});
|
||||
});
|
||||
|
||||