Compare commits
71 Commits
scrollToLo
...
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 | ||
|
|
803289ee1f | ||
|
|
76cdad4fe6 | ||
|
|
d03b30bc00 | ||
|
|
710d9ab8ac | ||
|
|
d008944022 | ||
|
|
f18bce6094 | ||
|
|
31eb00a155 | ||
|
|
a67c969189 | ||
|
|
58e6c6f23a | ||
|
|
f046d75b75 | ||
|
|
30bcfedc86 | ||
|
|
866b4d6cd4 | ||
|
|
a42938fa64 | ||
|
|
e02b0f9bc7 | ||
|
|
049a41f366 | ||
|
|
7f30680fb3 | ||
|
|
2d4256b239 | ||
|
|
247e3e8d93 | ||
|
|
5951b92668 | ||
|
|
a9ee670eb4 | ||
|
|
3990aaf38f | ||
|
|
48f5880f1d | ||
|
|
3332f58376 | ||
|
|
46ea8fbf72 | ||
|
|
6a21f8e3de | ||
|
|
f02ca4e3d2 |
39
.github/workflows/trafico.yml
vendored
@@ -1,39 +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
|
||||
pull_request_review:
|
||||
types: [submitted, edited, dismissed]
|
||||
|
||||
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}"
|
||||
1
.gitignore
vendored
@@ -21,6 +21,7 @@ packages/api/dist
|
||||
packages/api/@types
|
||||
packages/crdt/dist
|
||||
packages/desktop-electron/client-build
|
||||
packages/desktop-electron/build
|
||||
packages/desktop-electron/.electron-symbols
|
||||
packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "6.8.1",
|
||||
"version": "6.8.2",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
|
||||
@@ -44,7 +44,7 @@ export class RulesPage {
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('option', { exact: true, name: data.conditionsOp })
|
||||
.getByRole('button', { exact: true, name: data.conditionsOp })
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -97,13 +97,13 @@ export class RulesPage {
|
||||
if (field) {
|
||||
await row.getByRole('button').first().click();
|
||||
await this.page
|
||||
.getByRole('option', { exact: true, name: field })
|
||||
.getByRole('button', { exact: true, name: field })
|
||||
.click();
|
||||
}
|
||||
|
||||
if (op) {
|
||||
await row.getByRole('button', { name: 'is' }).click();
|
||||
await this.page.getByRole('option', { name: op, exact: true }).click();
|
||||
await this.page.getByRole('button', { name: op, exact: true }).click();
|
||||
}
|
||||
|
||||
if (value) {
|
||||
|
||||
@@ -84,6 +84,10 @@ export class SchedulesPage {
|
||||
|
||||
if (data.amount) {
|
||||
await this.page.getByLabel('Amount').fill(String(data.amount));
|
||||
// For some readon, the input field does not trigger the change event on tests
|
||||
// but it works on the browser. We can revisit this once migration to
|
||||
// react aria components is complete.
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 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: 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: 77 KiB After Width: | Height: | Size: 76 KiB |
@@ -89,7 +89,7 @@ test.describe('Rules', () => {
|
||||
splitActions: [
|
||||
[
|
||||
{
|
||||
field: 'a fixed percent',
|
||||
field: 'a fixed percent of the remainder',
|
||||
value: '90',
|
||||
},
|
||||
{
|
||||
@@ -120,7 +120,7 @@ test.describe('Rules', () => {
|
||||
});
|
||||
|
||||
const transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Ikea');
|
||||
await expect(transaction.payee).toHaveText('Split');
|
||||
await expect(transaction.notes).toHaveText('food / entertainment');
|
||||
await expect(transaction.category).toHaveText('Split');
|
||||
await expect(transaction.debit).toHaveText('100.00');
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 82 KiB |
@@ -120,7 +120,7 @@ test.describe('Transactions', () => {
|
||||
]);
|
||||
|
||||
const firstTransaction = accountPage.getNthTransaction(0);
|
||||
await expect(firstTransaction.payee).toHaveText('Krogger');
|
||||
await expect(firstTransaction.payee).toHaveText('Split');
|
||||
await expect(firstTransaction.notes).toHaveText('Notes');
|
||||
await expect(firstTransaction.category).toHaveText('Split');
|
||||
await expect(firstTransaction.debit).toHaveText('333.33');
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "24.6.0",
|
||||
"version": "24.7.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -8,7 +8,6 @@
|
||||
"devDependencies": {
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@playwright/test": "1.41.1",
|
||||
"@reach/listbox": "^0.18.0",
|
||||
"@react-aria/focus": "^3.16.0",
|
||||
"@react-aria/listbox": "^3.11.3",
|
||||
"@react-aria/utils": "^3.23.0",
|
||||
@@ -48,8 +47,9 @@
|
||||
"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.1.1",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
|
||||
3
packages/desktop-client/public/shortcut-accounts.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M1.857 7.612H.98a.98.98 0 0 0-.98.98v2.224c0 .449.184.857.51 1.163.612.551 1.816 1.49 3.735 2.368a.571.571 0 0 1 .306.346l.939 3.286a.484.484 0 0 0 .469.347h1.47a.479.479 0 0 0 .469-.367l.47-1.817a.262.262 0 0 1 .326-.183c.571.122 1.183.163 1.795.163.613 0 1.225-.061 1.796-.163.143-.02.286.06.327.183l.47 1.817a.502.502 0 0 0 .468.367h1.47a.484.484 0 0 0 .47-.347l1.224-4.245a.487.487 0 0 1 .122-.224c.919-.98 1.53-2.163 1.735-3.47h.49c.53 0 .959-.448.938-.979-.02-.51-.47-.918-.98-.918h-.448C18.06 4.673 14.632 2 10.489 2c-1.775 0-3.428.49-4.755 1.326-.591-.408-1.469-.734-2.693-.632-.49.04-.715.612-.388.959.408.429.796 1 .877 1.735L1.857 7.612Zm3.122.98a.862.862 0 0 1-.857-.858c0-.469.388-.857.857-.857.47 0 .858.388.858.857 0 .47-.388.858-.858.858Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 838 B |
3
packages/desktop-client/public/shortcut-reports.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M19 18h-1v-8c0-.6-.4-1-1-1s-1 .4-1 1v8h-3V1c0-.6-.4-1-1-1s-1 .4-1 1v17H8V7c0-.6-.4-1-1-1s-1 .4-1 1v11H3V3c0-.6-.4-1-1-1s-1 .4-1 1v15c-.6 0-1 .4-1 1s.4 1 1 1h18c.6 0 1-.4 1-1s-.4-1-1-1z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 268 B |
4
packages/desktop-client/public/shortcut-transaction.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23 11.5a1.5 1.5 0 0 1-1.5 1.5h-20a1.5 1.5 0 0 1 0-3h20a1.5 1.5 0 0 1 1.5 1.5Z" />
|
||||
<path d="M11.5 23a1.5 1.5 0 0 1-1.5-1.5v-20a1.5 1.5 0 0 1 3 0v20a1.5 1.5 0 0 1-1.5 1.5Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -28,6 +28,44 @@
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Add Transaction",
|
||||
"short_name": "Add Transaction",
|
||||
"description": "Add a new transaction",
|
||||
"url": "/transactions/new",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/shortcut-transaction.svg",
|
||||
"sizes": "150x150"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Accounts",
|
||||
"short_name": "Accounts",
|
||||
"description": "View all accounts",
|
||||
"url": "/accounts",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/shortcut-accounts.svg",
|
||||
"sizes": "150x150"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Reports",
|
||||
"short_name": "Reports",
|
||||
"description": "View reports",
|
||||
"url": "/reports",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/shortcut-reports.svg",
|
||||
"sizes": "150x150"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshot_wide.png",
|
||||
@@ -43,7 +81,7 @@
|
||||
"type": "image/png",
|
||||
"sizes": "350x600"
|
||||
}
|
||||
],
|
||||
],
|
||||
"theme_color": "#8812E1",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
|
||||
@@ -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
|
||||
@@ -173,6 +176,7 @@ export function Modals() {
|
||||
<ConfirmTransactionEdit
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onCancel={options.onCancel}
|
||||
onConfirm={options.onConfirm}
|
||||
confirmReason={options.confirmReason}
|
||||
/>
|
||||
@@ -401,7 +405,6 @@ export function Modals() {
|
||||
actions={actions}
|
||||
transactionIds={options?.transactionIds}
|
||||
getTransaction={options?.getTransaction}
|
||||
pushModal={options?.pushModal}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { PureComponent, createRef, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Navigate, useParams, useLocation, useMatch } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate, useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
import { debounce } from 'debounce';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { validForTransfer } from 'loot-core/client/transfer';
|
||||
import * as actions from 'loot-core/src/client/actions';
|
||||
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';
|
||||
@@ -22,10 +24,13 @@ import {
|
||||
realizeTempTransactions,
|
||||
ungroupTransaction,
|
||||
ungroupTransactions,
|
||||
makeChild,
|
||||
makeAsNonChildTransactions,
|
||||
} from 'loot-core/src/shared/transactions';
|
||||
import { applyChanges, groupById } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
|
||||
@@ -38,7 +43,7 @@ import {
|
||||
useSplitsExpanded,
|
||||
} from '../../hooks/useSplitsExpanded';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { TransactionList } from '../transactions/TransactionList';
|
||||
@@ -71,7 +76,7 @@ function EmptyMessage({ onAdd }) {
|
||||
manage it locally yourself.
|
||||
</Text>
|
||||
|
||||
<Button type="primary" style={{ marginTop: 20 }} onClick={onAdd}>
|
||||
<Button variant="primary" style={{ marginTop: 20 }} onPress={onAdd}>
|
||||
Add account
|
||||
</Button>
|
||||
|
||||
@@ -179,7 +184,9 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
this.state = {
|
||||
search: '',
|
||||
filters: props.conditions || [],
|
||||
filterConditions: props.filterConditions || [],
|
||||
filterId: [],
|
||||
filterConditionsOp: 'and',
|
||||
loading: true,
|
||||
workingHard: false,
|
||||
reconcileAmount: null,
|
||||
@@ -192,9 +199,8 @@ class AccountInternal extends PureComponent {
|
||||
editingName: false,
|
||||
isAdding: false,
|
||||
latestDate: null,
|
||||
filterId: [],
|
||||
conditionsOp: 'and',
|
||||
sort: [],
|
||||
filteredAmount: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,7 +262,7 @@ class AccountInternal extends PureComponent {
|
||||
// Important that any async work happens last so that the
|
||||
// listeners are set up synchronously
|
||||
await this.props.initiallyLoadPayees();
|
||||
await this.fetchTransactions(this.state.filters);
|
||||
await this.fetchTransactions(this.state.filterConditions);
|
||||
|
||||
// If there is a pending undo, apply it immediately (this happens
|
||||
// when an undo changes the location to this page)
|
||||
@@ -285,7 +291,7 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
//Resest sort/filter/search on account change
|
||||
if (this.props.accountId !== prevProps.accountId) {
|
||||
this.setState({ sort: [], search: '', filters: [] });
|
||||
this.setState({ sort: [], search: '', filterConditions: [] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,10 +319,10 @@ class AccountInternal extends PureComponent {
|
||||
this.paged?.run();
|
||||
};
|
||||
|
||||
fetchTransactions = filters => {
|
||||
fetchTransactions = filterConditions => {
|
||||
const query = this.makeRootQuery();
|
||||
this.rootQuery = this.currentQuery = query;
|
||||
if (filters) this.applyFilters(filters);
|
||||
if (filterConditions) this.applyFilters(filterConditions);
|
||||
else this.updateQuery(query);
|
||||
|
||||
if (this.props.accountId) {
|
||||
@@ -371,6 +377,7 @@ class AccountInternal extends PureComponent {
|
||||
balances: this.state.showBalances
|
||||
? await this.calculateBalances()
|
||||
: null,
|
||||
filteredAmount: await this.getFilteredAmount(),
|
||||
},
|
||||
() => {
|
||||
if (firstLoad) {
|
||||
@@ -418,7 +425,10 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
onSearchDone = debounce(() => {
|
||||
if (this.state.search === '') {
|
||||
this.updateQuery(this.currentQuery, this.state.filters.length > 0);
|
||||
this.updateQuery(
|
||||
this.currentQuery,
|
||||
this.state.filterConditions.length > 0,
|
||||
);
|
||||
} else {
|
||||
this.updateQuery(
|
||||
queries.makeTransactionSearchQuery(
|
||||
@@ -511,7 +521,7 @@ class AccountInternal extends PureComponent {
|
||||
return (
|
||||
account &&
|
||||
this.state.search === '' &&
|
||||
this.state.filters.length === 0 &&
|
||||
this.state.filterConditions.length === 0 &&
|
||||
(this.state.sort.length === 0 ||
|
||||
(this.state.sort.field === 'date' &&
|
||||
this.state.sort.ascDesc === 'desc'))
|
||||
@@ -599,7 +609,7 @@ class AccountInternal extends PureComponent {
|
||||
{
|
||||
transactions: [],
|
||||
transactionCount: 0,
|
||||
filters: [],
|
||||
filterConditions: [],
|
||||
search: '',
|
||||
sort: [],
|
||||
showBalances: true,
|
||||
@@ -612,9 +622,9 @@ class AccountInternal extends PureComponent {
|
||||
break;
|
||||
case 'remove-sorting': {
|
||||
this.setState({ sort: [] }, () => {
|
||||
const filters = this.state.filters;
|
||||
if (filters.length > 0) {
|
||||
this.applyFilters([...filters]);
|
||||
const filterConditions = this.state.filterConditions;
|
||||
if (filterConditions.length > 0) {
|
||||
this.applyFilters([...filterConditions]);
|
||||
} else {
|
||||
this.fetchTransactions();
|
||||
}
|
||||
@@ -637,12 +647,12 @@ class AccountInternal extends PureComponent {
|
||||
if (this.state.showReconciled) {
|
||||
this.props.savePrefs({ ['hide-reconciled-' + accountId]: true });
|
||||
this.setState({ showReconciled: false }, () =>
|
||||
this.fetchTransactions(this.state.filters),
|
||||
this.fetchTransactions(this.state.filterConditions),
|
||||
);
|
||||
} else {
|
||||
this.props.savePrefs({ ['hide-reconciled-' + accountId]: false });
|
||||
this.setState({ showReconciled: true }, () =>
|
||||
this.fetchTransactions(this.state.filters),
|
||||
this.fetchTransactions(this.state.filterConditions),
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -680,24 +690,11 @@ class AccountInternal extends PureComponent {
|
||||
};
|
||||
}
|
||||
|
||||
getFilteredAmount = async (filters, conditionsOpKey) => {
|
||||
const filter = queries.getAccountFilter(this.props.accountId);
|
||||
|
||||
let query = q('transactions').filter({
|
||||
[conditionsOpKey]: [...filters],
|
||||
});
|
||||
if (filter) {
|
||||
query = query.filter(filter);
|
||||
}
|
||||
|
||||
const filteredQuery = await runQuery(
|
||||
query.select([{ amount: { $sum: '$amount' } }]),
|
||||
getFilteredAmount = async () => {
|
||||
const { data: amount } = await runQuery(
|
||||
this.paged.getQuery().calculate({ $sum: '$amount' }),
|
||||
);
|
||||
const filteredAmount = filteredQuery.data.reduce(
|
||||
(a, v) => (a = a + v.amount),
|
||||
0,
|
||||
);
|
||||
return filteredAmount;
|
||||
return amount;
|
||||
};
|
||||
|
||||
isNew = id => {
|
||||
@@ -817,7 +814,7 @@ class AccountInternal extends PureComponent {
|
||||
onShowTransactions = async ids => {
|
||||
this.onApplyFilter({
|
||||
customName: 'Selected transactions',
|
||||
filter: { id: { $oneof: ids } },
|
||||
queryFilter: { id: { $oneof: ids } },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1058,6 +1055,114 @@ class AccountInternal extends PureComponent {
|
||||
);
|
||||
};
|
||||
|
||||
onMakeAsSplitTransaction = async ids => {
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const { data: transactions } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'none' }),
|
||||
);
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstTransaction] = transactions;
|
||||
const parentTransaction = {
|
||||
id: uuidv4(),
|
||||
is_parent: true,
|
||||
cleared: transactions.every(t => !!t.cleared),
|
||||
date: firstTransaction.date,
|
||||
account: firstTransaction.account,
|
||||
amount: transactions
|
||||
.map(t => t.amount)
|
||||
.reduce((total, amount) => total + amount, 0),
|
||||
};
|
||||
const childTransactions = transactions.map(t =>
|
||||
makeChild(parentTransaction, t),
|
||||
);
|
||||
|
||||
await send('transactions-batch-update', {
|
||||
added: [parentTransaction],
|
||||
updated: childTransactions,
|
||||
});
|
||||
|
||||
this.refetchTransactions();
|
||||
};
|
||||
|
||||
onMakeAsNonSplitTransactions = async ids => {
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const { data: groupedTransactions } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
|
||||
let changes = {
|
||||
updated: [],
|
||||
deleted: [],
|
||||
};
|
||||
|
||||
const groupedTransactionsToUpdate = groupedTransactions.filter(
|
||||
t => t.is_parent,
|
||||
);
|
||||
|
||||
for (const groupedTransaction of groupedTransactionsToUpdate) {
|
||||
const transactions = ungroupTransaction(groupedTransaction);
|
||||
const [parentTransaction, ...childTransactions] = transactions;
|
||||
|
||||
if (ids.includes(parentTransaction.id)) {
|
||||
// Unsplit all child transactions.
|
||||
const diff = makeAsNonChildTransactions(
|
||||
childTransactions,
|
||||
transactions,
|
||||
);
|
||||
|
||||
changes = {
|
||||
updated: [...changes.updated, ...diff.updated],
|
||||
deleted: [...changes.deleted, ...diff.deleted],
|
||||
};
|
||||
|
||||
// Already processed the child transactions above, no need to process them below.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unsplit selected child transactions.
|
||||
|
||||
const selectedChildTransactions = childTransactions.filter(t =>
|
||||
ids.includes(t.id),
|
||||
);
|
||||
|
||||
if (selectedChildTransactions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diff = makeAsNonChildTransactions(
|
||||
selectedChildTransactions,
|
||||
transactions,
|
||||
);
|
||||
|
||||
changes = {
|
||||
updated: [...changes.updated, ...diff.updated],
|
||||
deleted: [...changes.deleted, ...diff.deleted],
|
||||
};
|
||||
}
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
|
||||
this.refetchTransactions();
|
||||
|
||||
const transactionsToSelect = changes.updated.map(t => t.id);
|
||||
this.dispatchSelected({
|
||||
type: 'select-all',
|
||||
ids: transactionsToSelect,
|
||||
});
|
||||
};
|
||||
|
||||
checkForReconciledTransactions = async (ids, confirmReason, onConfirm) => {
|
||||
const { data } = await runQuery(
|
||||
q('transactions')
|
||||
@@ -1209,10 +1314,10 @@ class AccountInternal extends PureComponent {
|
||||
);
|
||||
};
|
||||
|
||||
onCondOpChange = (value, filters) => {
|
||||
this.setState({ conditionsOp: value });
|
||||
onConditionsOpChange = (value, conditions) => {
|
||||
this.setState({ filterConditionsOp: value });
|
||||
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
|
||||
this.applyFilters([...filters]);
|
||||
this.applyFilters([...conditions]);
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
}
|
||||
@@ -1220,14 +1325,14 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
onReloadSavedFilter = (savedFilter, item) => {
|
||||
if (item === 'reload') {
|
||||
const [getFilter] = this.props.filtersList.filter(
|
||||
const [savedFilter] = this.props.savedFilters.filter(
|
||||
f => f.id === this.state.filterId.id,
|
||||
);
|
||||
this.setState({ conditionsOp: getFilter.conditionsOp });
|
||||
this.applyFilters([...getFilter.conditions]);
|
||||
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
|
||||
this.applyFilters([...savedFilter.conditions]);
|
||||
} else {
|
||||
if (savedFilter.status) {
|
||||
this.setState({ conditionsOp: savedFilter.conditionsOp });
|
||||
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
|
||||
this.applyFilters([...savedFilter.conditions]);
|
||||
}
|
||||
}
|
||||
@@ -1235,7 +1340,7 @@ class AccountInternal extends PureComponent {
|
||||
};
|
||||
|
||||
onClearFilters = () => {
|
||||
this.setState({ conditionsOp: 'and' });
|
||||
this.setState({ filterConditionsOp: 'and' });
|
||||
this.setState({ filterId: [] });
|
||||
this.applyFilters([]);
|
||||
if (this.state.search !== '') {
|
||||
@@ -1243,9 +1348,11 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onUpdateFilter = (oldFilter, updatedFilter) => {
|
||||
onUpdateFilter = (oldCondition, updatedCondition) => {
|
||||
this.applyFilters(
|
||||
this.state.filters.map(f => (f === oldFilter ? updatedFilter : f)),
|
||||
this.state.filterConditions.map(c =>
|
||||
c === oldCondition ? updatedCondition : c,
|
||||
),
|
||||
);
|
||||
this.setState({
|
||||
filterId: {
|
||||
@@ -1258,11 +1365,11 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onDeleteFilter = filter => {
|
||||
this.applyFilters(this.state.filters.filter(f => f !== filter));
|
||||
if (this.state.filters.length === 1) {
|
||||
onDeleteFilter = condition => {
|
||||
this.applyFilters(this.state.filterConditions.filter(c => c !== condition));
|
||||
if (this.state.filterConditions.length === 1) {
|
||||
this.setState({ filterId: [] });
|
||||
this.setState({ conditionsOp: 'and' });
|
||||
this.setState({ filterConditionsOp: 'and' });
|
||||
} else {
|
||||
this.setState({
|
||||
filterId: {
|
||||
@@ -1276,23 +1383,31 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onApplyFilter = async cond => {
|
||||
let filters = this.state.filters;
|
||||
if (cond.customName) {
|
||||
filters = filters.filter(f => f.customName !== cond.customName);
|
||||
onApplyFilter = async conditionOrSavedFilter => {
|
||||
let filterConditions = this.state.filterConditions;
|
||||
if (conditionOrSavedFilter.customName) {
|
||||
filterConditions = filterConditions.filter(
|
||||
c => c.customName !== conditionOrSavedFilter.customName,
|
||||
);
|
||||
}
|
||||
if (cond.conditions) {
|
||||
this.setState({ filterId: { ...cond, status: 'saved' } });
|
||||
this.setState({ conditionsOp: cond.conditionsOp });
|
||||
this.applyFilters([...cond.conditions]);
|
||||
if (conditionOrSavedFilter.conditions) {
|
||||
// A saved filter was passed in.
|
||||
const savedFilter = conditionOrSavedFilter;
|
||||
this.setState({
|
||||
filterId: { ...savedFilter, status: 'saved' },
|
||||
});
|
||||
this.setState({ filterConditionsOp: savedFilter.conditionsOp });
|
||||
this.applyFilters([...savedFilter.conditions]);
|
||||
} else {
|
||||
// A condition was passed in.
|
||||
const condition = conditionOrSavedFilter;
|
||||
this.setState({
|
||||
filterId: {
|
||||
...this.state.filterId,
|
||||
status: this.state.filterId && 'changed',
|
||||
},
|
||||
});
|
||||
this.applyFilters([...filters, cond]);
|
||||
this.applyFilters([...filterConditions, condition]);
|
||||
}
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
@@ -1320,30 +1435,35 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
applyFilters = async conditions => {
|
||||
if (conditions.length > 0) {
|
||||
const customFilters = conditions
|
||||
const customQueryFilters = conditions
|
||||
.filter(cond => !!cond.customName)
|
||||
.map(f => f.filter);
|
||||
const { filters } = await send('make-filters-from-conditions', {
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
});
|
||||
const conditionsOpKey = this.state.conditionsOp === 'or' ? '$or' : '$and';
|
||||
this.filteredAmount = await this.getFilteredAmount(
|
||||
filters,
|
||||
conditionsOpKey,
|
||||
.map(f => f.queryFilter);
|
||||
const { filters: queryFilters } = await send(
|
||||
'make-filters-from-conditions',
|
||||
{
|
||||
conditions: conditions.filter(cond => !cond.customName),
|
||||
},
|
||||
);
|
||||
const conditionsOpKey =
|
||||
this.state.filterConditionsOp === 'or' ? '$or' : '$and';
|
||||
this.currentQuery = this.rootQuery.filter({
|
||||
[conditionsOpKey]: [...filters, ...customFilters],
|
||||
[conditionsOpKey]: [...queryFilters, ...customQueryFilters],
|
||||
});
|
||||
|
||||
this.setState({ filters: conditions }, () => {
|
||||
this.updateQuery(this.currentQuery, true);
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
filterConditions: conditions,
|
||||
},
|
||||
() => {
|
||||
this.updateQuery(this.currentQuery, true);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.setState(
|
||||
{
|
||||
transactions: [],
|
||||
transactionCount: 0,
|
||||
filters: conditions,
|
||||
filterConditions: conditions,
|
||||
},
|
||||
() => {
|
||||
this.fetchTransactions();
|
||||
@@ -1357,8 +1477,8 @@ class AccountInternal extends PureComponent {
|
||||
};
|
||||
|
||||
applySort = (field, ascDesc, prevField, prevAscDesc) => {
|
||||
const filters = this.state.filters;
|
||||
const isFiltered = filters.length > 0;
|
||||
const filterConditions = this.state.filterConditions;
|
||||
const isFiltered = filterConditions.length > 0;
|
||||
const sortField = getField(!field ? this.state.sort.field : field);
|
||||
const sortAscDesc = !ascDesc ? this.state.sort.ascDesc : ascDesc;
|
||||
const sortPrevField = getField(
|
||||
@@ -1425,7 +1545,7 @@ class AccountInternal extends PureComponent {
|
||||
// called directly from UI by sorting a column.
|
||||
// active filters need to be applied before sorting
|
||||
case isFiltered:
|
||||
this.applyFilters([...filters]);
|
||||
this.applyFilters([...filterConditions]);
|
||||
sortCurrentQuery(this, sortField, sortAscDesc);
|
||||
break;
|
||||
|
||||
@@ -1487,7 +1607,6 @@ class AccountInternal extends PureComponent {
|
||||
addNotification,
|
||||
accountsSyncing,
|
||||
failedAccounts,
|
||||
pushModal,
|
||||
replaceModal,
|
||||
showExtraBalances,
|
||||
accountId,
|
||||
@@ -1505,6 +1624,7 @@ class AccountInternal extends PureComponent {
|
||||
balances,
|
||||
showCleared,
|
||||
showReconciled,
|
||||
filteredAmount,
|
||||
} = this.state;
|
||||
|
||||
const account = accounts.find(account => account.id === accountId);
|
||||
@@ -1548,14 +1668,13 @@ class AccountInternal extends PureComponent {
|
||||
>
|
||||
<View style={styles.page}>
|
||||
<AccountHeader
|
||||
filteredAmount={this.filteredAmount}
|
||||
tableRef={this.table}
|
||||
editingName={editingName}
|
||||
isNameEditable={isNameEditable}
|
||||
workingHard={workingHard}
|
||||
account={account}
|
||||
filterId={filterId}
|
||||
filtersList={this.props.filtersList}
|
||||
savedFilters={this.props.savedFilters}
|
||||
location={this.props.location}
|
||||
accountName={accountName}
|
||||
accountsSyncing={accountsSyncing}
|
||||
@@ -1569,11 +1688,13 @@ class AccountInternal extends PureComponent {
|
||||
showEmptyMessage={showEmptyMessage}
|
||||
balanceQuery={balanceQuery}
|
||||
canCalculateBalance={this.canCalculateBalance}
|
||||
filteredAmount={filteredAmount}
|
||||
isFiltered={transactionsFiltered}
|
||||
isSorted={this.state.sort.length !== 0}
|
||||
reconcileAmount={reconcileAmount}
|
||||
search={this.state.search}
|
||||
filters={this.state.filters}
|
||||
conditionsOp={this.state.conditionsOp}
|
||||
filterConditions={this.state.filterConditions}
|
||||
filterConditionsOp={this.state.filterConditionsOp}
|
||||
savePrefs={this.props.savePrefs}
|
||||
pushModal={this.props.pushModal}
|
||||
onSearch={this.onSearch}
|
||||
@@ -1598,11 +1719,13 @@ class AccountInternal extends PureComponent {
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onClearFilters={this.onClearFilters}
|
||||
onReloadSavedFilter={this.onReloadSavedFilter}
|
||||
onCondOpChange={this.onCondOpChange}
|
||||
onConditionsOpChange={this.onConditionsOpChange}
|
||||
onDeleteFilter={this.onDeleteFilter}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onSetTransfer={this.onSetTransfer}
|
||||
onMakeAsSplitTransaction={this.onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={this.onMakeAsNonSplitTransactions}
|
||||
/>
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -1631,9 +1754,7 @@ class AccountInternal extends PureComponent {
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
isFiltered={
|
||||
this.state.search !== '' || this.state.filters.length > 0
|
||||
}
|
||||
isFiltered={transactionsFiltered}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
addNotification={addNotification}
|
||||
@@ -1653,7 +1774,6 @@ class AccountInternal extends PureComponent {
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
pushModal={pushModal}
|
||||
onSort={this.onSort}
|
||||
sortField={this.state.sort.field}
|
||||
ascDesc={this.state.sort.ascDesc}
|
||||
@@ -1669,6 +1789,7 @@ class AccountInternal extends PureComponent {
|
||||
this.setState({ isAdding: false })
|
||||
}
|
||||
onCreatePayee={this.onCreatePayee}
|
||||
onApplyFilter={this.onApplyFilter}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -1681,13 +1802,11 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
function AccountHack(props) {
|
||||
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
|
||||
const match = useMatch(props.location.pathname);
|
||||
|
||||
return (
|
||||
<AccountInternal
|
||||
{...props}
|
||||
match={match}
|
||||
splitsExpandedDispatch={splitsExpandedDispatch}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1716,75 +1835,41 @@ export function Account() {
|
||||
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
|
||||
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
|
||||
const lastUndoState = useSelector(state => state.app.lastUndoState);
|
||||
const conditions =
|
||||
location.state && location.state.conditions
|
||||
? location.state.conditions
|
||||
: [];
|
||||
const filterConditions = location?.state?.filterConditions || [];
|
||||
|
||||
const state = {
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
accounts,
|
||||
failedAccounts,
|
||||
dateFormat,
|
||||
hideFraction,
|
||||
expandSplits,
|
||||
showBalances,
|
||||
showCleared: !hideCleared,
|
||||
showReconciled: !hideReconciled,
|
||||
showExtraBalances,
|
||||
payees,
|
||||
modalShowing,
|
||||
accountsSyncing,
|
||||
lastUndoState,
|
||||
conditions,
|
||||
};
|
||||
const savedFiters = useFilters();
|
||||
const actionCreators = useActions();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const filtersList = useFilters();
|
||||
const actionCreators = useMemo(
|
||||
() => bindActionCreators(actions, dispatch),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
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}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={state.expandSplits ? 'collapse' : 'expand'}
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
<AccountHack
|
||||
{...state}
|
||||
newTransactions={newTransactions}
|
||||
matchedTransactions={matchedTransactions}
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={hideFraction}
|
||||
expandSplits={expandSplits}
|
||||
showBalances={showBalances}
|
||||
showCleared={!hideCleared}
|
||||
showReconciled={!hideReconciled}
|
||||
showExtraBalances={showExtraBalances}
|
||||
payees={payees}
|
||||
modalShowing={modalShowing}
|
||||
accountsSyncing={accountsSyncing}
|
||||
lastUndoState={lastUndoState}
|
||||
filterConditions={filterConditions}
|
||||
categoryGroups={categoryGroups}
|
||||
{...actionCreators}
|
||||
modalShowing={state.modalShowing}
|
||||
accountId={params.id}
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
filtersList={filtersList}
|
||||
savedFilters={savedFiters}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import { SvgExclamationOutline } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Link } from '../common/Link';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
@@ -94,7 +94,7 @@ export function AccountSyncCheck() {
|
||||
<View>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -103,7 +103,7 @@ export function AccountSyncCheck() {
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
onPress={() => setOpen(true)}
|
||||
>
|
||||
<SvgExclamationOutline
|
||||
style={{ width: 14, height: 14, marginRight: 5 }}
|
||||
@@ -129,13 +129,17 @@ export function AccountSyncCheck() {
|
||||
<View style={{ justifyContent: 'flex-end', flexDirection: 'row' }}>
|
||||
{showAuth ? (
|
||||
<>
|
||||
<Button onClick={unlink}>Unlink</Button>
|
||||
<Button type="primary" onClick={reauth} style={{ marginLeft: 5 }}>
|
||||
<Button onPress={unlink}>Unlink</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={reauth}
|
||||
style={{ marginLeft: 5 }}
|
||||
>
|
||||
Reauthorize
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={unlink}>Unlink account</Button>
|
||||
<Button onPress={unlink}>Unlink account</Button>
|
||||
)}
|
||||
</View>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { useHover } from 'usehooks-ts';
|
||||
|
||||
import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules';
|
||||
@@ -8,7 +10,7 @@ import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import { useSelectedItems } from '../../hooks/useSelected';
|
||||
import { SvgArrowButtonRight1 } from '../../icons/v2';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { PrivacyFilter } from '../PrivacyFilter';
|
||||
@@ -137,10 +139,12 @@ export function Balances({
|
||||
showExtraBalances,
|
||||
onToggleExtraBalances,
|
||||
account,
|
||||
filteredItems,
|
||||
isFiltered,
|
||||
filteredAmount,
|
||||
}) {
|
||||
const selectedItems = useSelectedItems();
|
||||
const buttonRef = useRef(null);
|
||||
const isButtonHovered = useHover(buttonRef);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -152,14 +156,11 @@ export function Balances({
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
data-testid="account-balance"
|
||||
type="bare"
|
||||
onClick={onToggleExtraBalances}
|
||||
variant="bare"
|
||||
onPress={onToggleExtraBalances}
|
||||
style={{
|
||||
'& svg': {
|
||||
opacity: selectedItems.size > 0 || showExtraBalances ? 1 : 0,
|
||||
},
|
||||
'&:hover svg': { opacity: 1 },
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
}}
|
||||
@@ -188,6 +189,10 @@ export function Balances({
|
||||
marginLeft: 10,
|
||||
color: theme.pillText,
|
||||
transform: showExtraBalances ? 'rotateZ(180deg)' : 'rotateZ(0)',
|
||||
opacity:
|
||||
isButtonHovered || selectedItems.size > 0 || showExtraBalances
|
||||
? 1
|
||||
: 0,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
@@ -196,9 +201,7 @@ export function Balances({
|
||||
{selectedItems.size > 0 && (
|
||||
<SelectedBalance selectedItems={selectedItems} account={account} />
|
||||
)}
|
||||
{filteredItems.length > 0 && (
|
||||
<FilteredBalance filteredAmount={filteredAmount} />
|
||||
)}
|
||||
{isFiltered && <FilteredBalance filteredAmount={filteredAmount} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '../../icons/v2';
|
||||
import { theme, styles } from '../../style';
|
||||
import { AnimatedRefresh } from '../AnimatedRefresh';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
@@ -26,13 +26,12 @@ import { View } from '../common/View';
|
||||
import { FilterButton } from '../filters/FiltersMenu';
|
||||
import { FiltersStack } from '../filters/FiltersStack';
|
||||
import { NotesButton } from '../NotesButton';
|
||||
import { SelectedTransactionsButton } from '../transactions/SelectedTransactions';
|
||||
import { SelectedTransactionsButton } from '../transactions/SelectedTransactionsButton';
|
||||
|
||||
import { Balances } from './Balance';
|
||||
import { ReconcilingMessage, ReconcileMenu } from './Reconcile';
|
||||
|
||||
export function AccountHeader({
|
||||
filteredAmount,
|
||||
tableRef,
|
||||
editingName,
|
||||
isNameEditable,
|
||||
@@ -40,7 +39,7 @@ export function AccountHeader({
|
||||
accountName,
|
||||
account,
|
||||
filterId,
|
||||
filtersList,
|
||||
savedFilters,
|
||||
accountsSyncing,
|
||||
failedAccounts,
|
||||
accounts,
|
||||
@@ -53,10 +52,12 @@ export function AccountHeader({
|
||||
balanceQuery,
|
||||
reconcileAmount,
|
||||
canCalculateBalance,
|
||||
isFiltered,
|
||||
filteredAmount,
|
||||
isSorted,
|
||||
search,
|
||||
filters,
|
||||
conditionsOp,
|
||||
filterConditions,
|
||||
filterConditionsOp,
|
||||
pushModal,
|
||||
onSearch,
|
||||
onAddTransaction,
|
||||
@@ -79,10 +80,12 @@ export function AccountHeader({
|
||||
onUpdateFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
onDeleteFilter,
|
||||
onScheduleAction,
|
||||
onSetTransfer,
|
||||
onMakeAsSplitTransaction,
|
||||
onMakeAsNonSplitTransactions,
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const searchInput = useRef(null);
|
||||
@@ -211,10 +214,10 @@ export function AccountHeader({
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Edit account name"
|
||||
className="hover-visible"
|
||||
onClick={() => onExposeName(true)}
|
||||
onPress={() => onExposeName(true)}
|
||||
>
|
||||
<SvgPencil1
|
||||
style={{
|
||||
@@ -243,7 +246,7 @@ export function AccountHeader({
|
||||
showExtraBalances={showExtraBalances}
|
||||
onToggleExtraBalances={onToggleExtraBalances}
|
||||
account={account}
|
||||
filteredItems={filters}
|
||||
isFiltered={isFiltered}
|
||||
filteredAmount={filteredAmount}
|
||||
/>
|
||||
|
||||
@@ -255,9 +258,9 @@ export function AccountHeader({
|
||||
>
|
||||
{((account && !account.closed) || canSync) && (
|
||||
<Button
|
||||
type="bare"
|
||||
onClick={canSync ? onSync : onImport}
|
||||
disabled={canSync && isServerOffline}
|
||||
variant="bare"
|
||||
onPress={canSync ? onSync : onImport}
|
||||
isDisabled={canSync && isServerOffline}
|
||||
>
|
||||
{canSync ? (
|
||||
<>
|
||||
@@ -286,7 +289,7 @@ export function AccountHeader({
|
||||
</Button>
|
||||
)}
|
||||
{!showEmptyMessage && (
|
||||
<Button type="bare" onClick={onAddTransaction}>
|
||||
<Button variant="bare" onPress={onAddTransaction}>
|
||||
<SvgAdd width={10} height={10} style={{ marginRight: 3 }} /> Add
|
||||
New
|
||||
</Button>
|
||||
@@ -318,24 +321,34 @@ export function AccountHeader({
|
||||
onScheduleAction={onScheduleAction}
|
||||
pushModal={pushModal}
|
||||
showMakeTransfer={showMakeTransfer}
|
||||
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
|
||||
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="bare"
|
||||
disabled={search !== '' || filters.length > 0}
|
||||
style={{ padding: 6, marginLeft: 10 }}
|
||||
onClick={onToggleSplits}
|
||||
title={
|
||||
variant="bare"
|
||||
aria-label={
|
||||
splitsExpanded.state.mode === 'collapse'
|
||||
? 'Collapse split transactions'
|
||||
: 'Expand split transactions'
|
||||
}
|
||||
isDisabled={search !== '' || filterConditions.length > 0}
|
||||
style={{ padding: 6, marginLeft: 10 }}
|
||||
onPress={onToggleSplits}
|
||||
>
|
||||
{splitsExpanded.state.mode === 'collapse' ? (
|
||||
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
|
||||
) : (
|
||||
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
<View
|
||||
title={
|
||||
splitsExpanded.state.mode === 'collapse'
|
||||
? 'Collapse split transactions'
|
||||
: 'Expand split transactions'
|
||||
}
|
||||
>
|
||||
{splitsExpanded.state.mode === 'collapse' ? (
|
||||
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
|
||||
) : (
|
||||
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
{account ? (
|
||||
<View>
|
||||
@@ -391,17 +404,17 @@ export function AccountHeader({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{filters && filters.length > 0 && (
|
||||
{filterConditions?.length > 0 && (
|
||||
<FiltersStack
|
||||
filters={filters}
|
||||
conditionsOp={conditionsOp}
|
||||
conditions={filterConditions}
|
||||
conditionsOp={filterConditionsOp}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
filterId={filterId}
|
||||
filtersList={filtersList}
|
||||
onCondOpChange={onCondOpChange}
|
||||
savedFilters={savedFilters}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { currencyToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { SvgCheckCircle1 } from '../../icons/v2';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -78,13 +78,13 @@ export function ReconcilingMessage({
|
||||
</View>
|
||||
)}
|
||||
<View style={{ marginLeft: 15 }}>
|
||||
<Button type="primary" onClick={onDone}>
|
||||
<Button variant="primary" onPress={onDone}>
|
||||
Done Reconciling
|
||||
</Button>
|
||||
</View>
|
||||
{targetDiff !== 0 && (
|
||||
<View style={{ marginLeft: 15 }}>
|
||||
<Button onClick={() => onCreateTransaction(targetDiff)}>
|
||||
<Button onPress={() => onCreateTransaction(targetDiff)}>
|
||||
Create Reconciliation Transaction
|
||||
</Button>
|
||||
</View>
|
||||
@@ -102,17 +102,20 @@ export function ReconcileMenu({ account, onReconcile, onClose }) {
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
});
|
||||
const format = useFormat();
|
||||
const [inputValue, setInputValue] = useState(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
const input = e.target.elements[0];
|
||||
const amount = currencyToInteger(input.value);
|
||||
if (amount != null) {
|
||||
onReconcile(amount == null ? clearedBalance : amount);
|
||||
onClose();
|
||||
} else {
|
||||
input.select();
|
||||
function onSubmit() {
|
||||
if (inputValue === '') {
|
||||
setInputFocused(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -121,17 +124,20 @@ export function ReconcileMenu({ account, onReconcile, onClose }) {
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Text>
|
||||
<form onSubmit={onSubmit}>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
style={{ margin: '7px 0' }}
|
||||
/>
|
||||
</InitialFocus>
|
||||
)}
|
||||
<Button type="primary">Reconcile</Button>
|
||||
</form>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
focused={inputFocused}
|
||||
onEnter={onSubmit}
|
||||
/>
|
||||
</InitialFocus>
|
||||
)}
|
||||
<Button variant="primary" onPress={onSubmit}>
|
||||
Reconcile
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgArrowThinRight } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
@@ -12,24 +11,51 @@ import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
|
||||
import { makeBalanceAmountStyle } from './util';
|
||||
|
||||
type CarryoverIndicatorProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
type BalanceWithCarryoverProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof CellValue>,
|
||||
'binding'
|
||||
> & {
|
||||
carryover: Binding;
|
||||
balance: Binding;
|
||||
goal?: Binding;
|
||||
budgeted?: Binding;
|
||||
goal: Binding;
|
||||
budgeted: Binding;
|
||||
disabled?: boolean;
|
||||
carryoverStyle?: CSSProperties;
|
||||
carryoverIndicator?: ({ style }: CarryoverIndicatorProps) => JSX.Element;
|
||||
};
|
||||
|
||||
export function DefaultCarryoverIndicator({ style }: CarryoverIndicatorProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 2,
|
||||
position: 'absolute',
|
||||
right: '-4px',
|
||||
alignSelf: 'center',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<SvgArrowThinRight
|
||||
width={style?.width || 7}
|
||||
height={style?.height || 7}
|
||||
style={style}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function BalanceWithCarryover({
|
||||
carryover,
|
||||
balance,
|
||||
goal,
|
||||
budgeted,
|
||||
disabled,
|
||||
carryoverStyle,
|
||||
carryoverIndicator = DefaultCarryoverIndicator,
|
||||
...props
|
||||
}: BalanceWithCarryoverProps) {
|
||||
const carryoverValue = useSheetValue(carryover);
|
||||
@@ -37,11 +63,21 @@ export function BalanceWithCarryover({
|
||||
const goalValue = useSheetValue(goal);
|
||||
const budgetedValue = useSheetValue(budgeted);
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const valueStyle = makeBalanceAmountStyle(
|
||||
balanceValue,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
budgetedValue,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'right',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<CellValue
|
||||
{...props}
|
||||
binding={balance}
|
||||
@@ -54,6 +90,8 @@ export function BalanceWithCarryover({
|
||||
)
|
||||
}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'right',
|
||||
...(!disabled && {
|
||||
cursor: 'pointer',
|
||||
@@ -61,30 +99,7 @@ export function BalanceWithCarryover({
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
{carryoverValue && (
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
marginLeft: 2,
|
||||
position: 'absolute',
|
||||
right: isNarrowWidth ? '-8px' : '-4px',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
...carryoverStyle,
|
||||
}}
|
||||
>
|
||||
<SvgArrowThinRight
|
||||
width={carryoverStyle?.width || 7}
|
||||
height={carryoverStyle?.height || 7}
|
||||
style={makeBalanceAmountStyle(
|
||||
balanceValue,
|
||||
goalValue,
|
||||
budgetedValue,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
{carryoverValue && carryoverIndicator({ style: valueStyle })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const BudgetCategories = memo(
|
||||
function onCollapse(value) {
|
||||
setCollapsedGroupIdsPref(value);
|
||||
}
|
||||
|
||||
const [isAddingGroup, setIsAddingGroup] = useState(false);
|
||||
const [newCategoryForGroup, setNewCategoryForGroup] = useState(null);
|
||||
const items = useMemo(() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
@@ -30,38 +29,12 @@ export function BudgetTable(props) {
|
||||
onBudgetAction,
|
||||
} = props;
|
||||
|
||||
const budgetCategoriesRef = useRef();
|
||||
const scrollableDivRef = useRef();
|
||||
const location = useLocation();
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
|
||||
useLocalPref('budget.collapsed');
|
||||
const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref(
|
||||
'budget.showHiddenCategories',
|
||||
);
|
||||
|
||||
const getCurrentScrollPosition = () => {
|
||||
return scrollableDivRef.current?.scrollTop || 0;
|
||||
};
|
||||
|
||||
const onShowActivityWithScroll = (categoryId, month) => {
|
||||
const scrollPosition = getCurrentScrollPosition();
|
||||
onShowActivity(categoryId, month, scrollPosition);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedScrollPosition = location.state?.scrollPosition;
|
||||
|
||||
if (savedScrollPosition && scrollableDivRef.current) {
|
||||
// Use requestAnimationFrame to ensure the DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollableDivRef.current) {
|
||||
scrollableDivRef.current.scrollTop = savedScrollPosition;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [location.state?.scrollPosition]);
|
||||
|
||||
const [editing, setEditing] = useState(null);
|
||||
|
||||
const onEditMonth = (id, month) => {
|
||||
@@ -228,7 +201,6 @@ export function BudgetTable(props) {
|
||||
collapseAllCategories={collapseAllCategories}
|
||||
/>
|
||||
<View
|
||||
id="scrollableDiv"
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
overflowAnchor: 'none',
|
||||
@@ -236,7 +208,6 @@ export function BudgetTable(props) {
|
||||
paddingLeft: 5,
|
||||
paddingRight: 5,
|
||||
}}
|
||||
innerRef={scrollableDivRef}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@@ -257,7 +228,7 @@ export function BudgetTable(props) {
|
||||
onReorderCategory={_onReorderCategory}
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivityWithScroll}
|
||||
onShowActivity={onShowActivity}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -276,8 +264,8 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
dispatch(applyBudgetAction(month, type, args));
|
||||
};
|
||||
|
||||
const onShowActivity = (categoryId, month, scrollPosition) => {
|
||||
const conditions = [
|
||||
const onShowActivity = (categoryId, month) => {
|
||||
const filterConditions = [
|
||||
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
|
||||
{
|
||||
field: 'date',
|
||||
@@ -287,16 +275,10 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
navigate('/budget', {
|
||||
replace: true,
|
||||
state: { scrollPosition }
|
||||
});
|
||||
|
||||
navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
conditions,
|
||||
filterConditions,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
@@ -329,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) {
|
||||
@@ -422,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,
|
||||
@@ -466,7 +428,6 @@ export function Budget() {
|
||||
<BudgetInner
|
||||
reportComponents={reportComponents}
|
||||
rolloverComponents={rolloverComponents}
|
||||
titlebar={titlebar}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -302,9 +302,7 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
|
||||
<span
|
||||
data-testid="category-month-spent"
|
||||
onClick={() => {
|
||||
onShowActivity(category.id, month);
|
||||
}}
|
||||
onClick={() => onShowActivity(category.id, month)}
|
||||
>
|
||||
<CellValue
|
||||
binding={reportBudget.catSumAmount(category.id)}
|
||||
@@ -323,7 +321,6 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
{!category.is_income && (
|
||||
<Field
|
||||
name="balance"
|
||||
truncate={false}
|
||||
width="flex"
|
||||
style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }}
|
||||
>
|
||||
|
||||
@@ -295,9 +295,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
<Field name="spent" width="flex" style={{ textAlign: 'right' }}>
|
||||
<span
|
||||
data-testid="category-month-spent"
|
||||
onClick={() => {
|
||||
onShowActivity(category.id, month);
|
||||
}}
|
||||
onClick={() => onShowActivity(category.id, month)}
|
||||
>
|
||||
<CellValue
|
||||
binding={rolloverBudget.catSumAmount(category.id)}
|
||||
@@ -312,7 +310,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
</Field>
|
||||
<Field
|
||||
name="balance"
|
||||
truncate={false}
|
||||
width="flex"
|
||||
style={{ paddingRight: styles.monthRightPadding, textAlign: 'right' }}
|
||||
>
|
||||
|
||||
229
packages/desktop-client/src/components/common/Button2.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { forwardRef, type ComponentPropsWithoutRef } from 'react';
|
||||
import {
|
||||
Button as ReactAriaButton,
|
||||
type ButtonProps as ReactAriaButtonProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
import { type CSSProperties, styles, theme } from '../../style';
|
||||
|
||||
import { View } from './View';
|
||||
|
||||
const backgroundColor: {
|
||||
[key in ButtonVariant | `${ButtonVariant}Disabled`]?: string;
|
||||
} = {
|
||||
normal: theme.buttonNormalBackground,
|
||||
normalDisabled: theme.buttonNormalDisabledBackground,
|
||||
primary: theme.buttonPrimaryBackground,
|
||||
primaryDisabled: theme.buttonPrimaryDisabledBackground,
|
||||
bare: theme.buttonBareBackground,
|
||||
bareDisabled: theme.buttonBareDisabledBackground,
|
||||
menu: theme.buttonMenuBackground,
|
||||
menuSelected: theme.buttonMenuSelectedBackground,
|
||||
};
|
||||
|
||||
const backgroundColorHover: Record<
|
||||
ButtonVariant | `${ButtonVariant}Disabled`,
|
||||
CSSProperties['backgroundColor']
|
||||
> = {
|
||||
normal: theme.buttonNormalBackgroundHover,
|
||||
primary: theme.buttonPrimaryBackgroundHover,
|
||||
bare: theme.buttonBareBackgroundHover,
|
||||
menu: theme.buttonMenuBackgroundHover,
|
||||
menuSelected: theme.buttonMenuSelectedBackgroundHover,
|
||||
normalDisabled: 'transparent',
|
||||
primaryDisabled: 'transparent',
|
||||
bareDisabled: 'transparent',
|
||||
menuDisabled: 'transparent',
|
||||
menuSelectedDisabled: 'transparent',
|
||||
};
|
||||
|
||||
const borderColor: {
|
||||
[key in
|
||||
| ButtonVariant
|
||||
| `${ButtonVariant}Disabled`]?: CSSProperties['borderColor'];
|
||||
} = {
|
||||
normal: theme.buttonNormalBorder,
|
||||
normalDisabled: theme.buttonNormalDisabledBorder,
|
||||
primary: theme.buttonPrimaryBorder,
|
||||
primaryDisabled: theme.buttonPrimaryDisabledBorder,
|
||||
menu: theme.buttonMenuBorder,
|
||||
menuSelected: theme.buttonMenuSelectedBorder,
|
||||
};
|
||||
|
||||
const textColor: {
|
||||
[key in ButtonVariant | `${ButtonVariant}Disabled`]?: CSSProperties['color'];
|
||||
} = {
|
||||
normal: theme.buttonNormalText,
|
||||
normalDisabled: theme.buttonNormalDisabledText,
|
||||
primary: theme.buttonPrimaryText,
|
||||
primaryDisabled: theme.buttonPrimaryDisabledText,
|
||||
bare: theme.buttonBareText,
|
||||
bareDisabled: theme.buttonBareDisabledText,
|
||||
menu: theme.buttonMenuText,
|
||||
menuSelected: theme.buttonMenuSelectedText,
|
||||
};
|
||||
|
||||
const textColorHover: {
|
||||
[key in ButtonVariant]?: string;
|
||||
} = {
|
||||
normal: theme.buttonNormalTextHover,
|
||||
primary: theme.buttonPrimaryTextHover,
|
||||
bare: theme.buttonBareTextHover,
|
||||
menu: theme.buttonMenuTextHover,
|
||||
menuSelected: theme.buttonMenuSelectedTextHover,
|
||||
};
|
||||
|
||||
const _getBorder = (
|
||||
variant: ButtonVariant,
|
||||
variantWithDisabled: keyof typeof borderColor,
|
||||
): string => {
|
||||
switch (variant) {
|
||||
case 'bare':
|
||||
return 'none';
|
||||
|
||||
default:
|
||||
return '1px solid ' + borderColor[variantWithDisabled];
|
||||
}
|
||||
};
|
||||
|
||||
const _getPadding = (variant: ButtonVariant): string => {
|
||||
switch (variant) {
|
||||
case 'bare':
|
||||
return '5px';
|
||||
default:
|
||||
return '5px 10px';
|
||||
}
|
||||
};
|
||||
|
||||
const _getActiveStyles = (
|
||||
variant: ButtonVariant,
|
||||
bounce: boolean,
|
||||
): CSSProperties => {
|
||||
switch (variant) {
|
||||
case 'bare':
|
||||
return { backgroundColor: theme.buttonBareBackgroundActive };
|
||||
default:
|
||||
return {
|
||||
transform: bounce ? 'translateY(1px)' : undefined,
|
||||
boxShadow: `0 1px 4px 0 ${
|
||||
variant === 'primary'
|
||||
? theme.buttonPrimaryShadow
|
||||
: theme.buttonNormalShadow
|
||||
}`,
|
||||
transition: 'none',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type ButtonProps = ComponentPropsWithoutRef<typeof ReactAriaButton> & {
|
||||
variant?: ButtonVariant;
|
||||
bounce?: boolean;
|
||||
};
|
||||
|
||||
type ButtonVariant = 'normal' | 'primary' | 'bare' | 'menu' | 'menuSelected';
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
variant = 'normal',
|
||||
bounce = true,
|
||||
style,
|
||||
isDisabled,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const variantWithDisabled: ButtonVariant | `${ButtonVariant}Disabled` =
|
||||
isDisabled ? `${variant}Disabled` : variant;
|
||||
|
||||
const hoveredStyle = {
|
||||
...(variant !== 'bare' && styles.shadow),
|
||||
backgroundColor: backgroundColorHover[variant],
|
||||
color: textColorHover[variant],
|
||||
};
|
||||
const pressedStyle = {
|
||||
..._getActiveStyles(variant, bounce),
|
||||
};
|
||||
|
||||
const buttonStyle: ComponentPropsWithoutRef<
|
||||
typeof Button
|
||||
>['style'] = props => ({
|
||||
...props.defaultStyle,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
padding: _getPadding(variant),
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
borderRadius: 4,
|
||||
backgroundColor: backgroundColor[variantWithDisabled],
|
||||
border: _getBorder(variant, variantWithDisabled),
|
||||
color: textColor[variantWithDisabled],
|
||||
transition: 'box-shadow .25s',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
...styles.smallText,
|
||||
...(props.isHovered && !isDisabled ? hoveredStyle : {}),
|
||||
...(props.isPressed && !isDisabled ? pressedStyle : {}),
|
||||
...(typeof style === 'function' ? style(props) : style),
|
||||
});
|
||||
|
||||
return (
|
||||
<ReactAriaButton ref={ref} style={buttonStyle} {...restProps}>
|
||||
{children}
|
||||
</ReactAriaButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
type ButtonWithLoadingProps = ButtonProps & {
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export const ButtonWithLoading = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonWithLoadingProps
|
||||
>((props, ref) => {
|
||||
const { isLoading, children, ...buttonProps } = props;
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
ref={ref}
|
||||
style={{ position: 'relative', ...buttonProps.style }}
|
||||
>
|
||||
{renderProps => (
|
||||
<>
|
||||
{isLoading && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
opacity: isLoading ? 0 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? children(renderProps) : children}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
ButtonWithLoading.displayName = 'ButtonWithLoading';
|
||||
@@ -23,14 +23,6 @@ export const Popover = ({
|
||||
...style,
|
||||
})}`}
|
||||
shouldCloseOnInteractOutside={element => {
|
||||
// Disable closing the popover when a reach listbox is clicked (Select component)
|
||||
if (
|
||||
element.getAttribute('data-reach-listbox-list') !== null ||
|
||||
element.getAttribute('data-reach-listbox-option') !== null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldCloseOnInteractOutside) {
|
||||
return shouldCloseOnInteractOutside(element);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import {
|
||||
ListboxInput,
|
||||
ListboxButton,
|
||||
ListboxPopover,
|
||||
ListboxList,
|
||||
ListboxOption,
|
||||
} from '@reach/listbox';
|
||||
import { css } from 'glamor';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { theme, styles, type CSSProperties } from '../../style';
|
||||
import { type CSSProperties } from '../../style';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { Menu } from './Menu';
|
||||
import { Popover } from './Popover';
|
||||
import { View } from './View';
|
||||
|
||||
function isValueOption<Value extends string>(
|
||||
option: [Value, string] | typeof Menu.line,
|
||||
): option is [Value, string] {
|
||||
return option !== Menu.line;
|
||||
}
|
||||
|
||||
type SelectProps<Value extends string> = {
|
||||
bare?: boolean;
|
||||
options: Array<[Value, string]>;
|
||||
options: Array<[Value, string] | typeof Menu.line>;
|
||||
value: Value;
|
||||
defaultLabel?: string;
|
||||
onChange?: (newValue: Value) => void;
|
||||
style?: CSSProperties;
|
||||
wrapperStyle?: CSSProperties;
|
||||
line?: number;
|
||||
disabled?: boolean;
|
||||
disabledKeys?: Value[];
|
||||
buttonStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -29,7 +31,6 @@ type SelectProps<Value extends string> = {
|
||||
* @param {string} [defaultLabel] - The label to display when the selected value is not in the options.
|
||||
* @param {function} [onChange] - A callback function invoked when the selected value changes.
|
||||
* @param {CSSProperties} [style] - Custom styles to apply to the selected button.
|
||||
* @param {CSSProperties} [wrapperStyle] - Custom style to apply to the select wrapper.
|
||||
* @param {string[]} [disabledKeys] - An array of option values to disable.
|
||||
*
|
||||
* @example
|
||||
@@ -37,128 +38,96 @@ type SelectProps<Value extends string> = {
|
||||
* // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="1" onChange={handleOnChange} />
|
||||
* // <Select options={[['1', 'Option 1'], ['2', 'Option 2']]} value="3" defaultLabel="Select an option" onChange={handleOnChange} />
|
||||
*/
|
||||
|
||||
export function Select<Value extends string>({
|
||||
bare,
|
||||
options,
|
||||
value,
|
||||
defaultLabel = '',
|
||||
onChange,
|
||||
style,
|
||||
wrapperStyle,
|
||||
line,
|
||||
disabled,
|
||||
disabled = false,
|
||||
disabledKeys = [],
|
||||
buttonStyle = {},
|
||||
}: SelectProps<Value>) {
|
||||
const arrowSize = 7;
|
||||
const minHeight = style?.minHeight ? style.minHeight : '18px';
|
||||
const targetOption = options.filter(option => option[0] === value);
|
||||
const targetOption = options
|
||||
.filter(isValueOption)
|
||||
.find(option => option[0] === value);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ListboxInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
color: bare ? 'inherit' : theme.formInputText,
|
||||
backgroundColor: bare ? 'transparent' : theme.cardBackground,
|
||||
borderRadius: styles.menuBorderRadius,
|
||||
border: bare ? 'none' : '1px solid ' + theme.formInputBorder,
|
||||
lineHeight: '1em',
|
||||
...wrapperStyle,
|
||||
}}
|
||||
>
|
||||
<ListboxButton
|
||||
className={`${css([
|
||||
{ borderWidth: 0, padding: 5, borderRadius: 4 },
|
||||
style,
|
||||
])}`}
|
||||
arrow={
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type={bare ? 'bare' : 'normal'}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
style={buttonStyle}
|
||||
hoveredStyle={{
|
||||
backgroundColor: bare ? 'transparent' : undefined,
|
||||
...buttonStyle,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 5,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
width: 'calc(100% - 7px)',
|
||||
}}
|
||||
>
|
||||
{targetOption ? targetOption[1] : defaultLabel}
|
||||
</span>
|
||||
<SvgExpandArrow
|
||||
style={{
|
||||
width: arrowSize,
|
||||
height: arrowSize,
|
||||
width: 7,
|
||||
height: 7,
|
||||
color: 'inherit',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: `calc(100% - ${arrowSize + 5}px)`,
|
||||
alignItems: 'center',
|
||||
minHeight,
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
onChange?.(item);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{targetOption.length !== 0 ? targetOption[0][1] : defaultLabel}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<ListboxPopover
|
||||
style={{
|
||||
zIndex: 100000,
|
||||
outline: 0,
|
||||
borderRadius: styles.menuBorderRadius,
|
||||
backgroundColor: theme.menuBackground,
|
||||
color: theme.menuItemText,
|
||||
boxShadow: styles.cardShadow,
|
||||
border: '1px solid ' + theme.menuBorder,
|
||||
}}
|
||||
className={`${css({
|
||||
'[data-reach-listbox-option]': {
|
||||
background: theme.menuItemBackground,
|
||||
color: theme.menuItemText,
|
||||
},
|
||||
'[data-reach-listbox-option][data-current-nav]': {
|
||||
background: theme.menuItemBackgroundHover,
|
||||
color: theme.menuItemTextHover,
|
||||
},
|
||||
})}`}
|
||||
>
|
||||
{!line ? (
|
||||
<ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{options.map(([value, label]) => (
|
||||
<ListboxOption
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={disabledKeys.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
) : (
|
||||
<ListboxList style={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{options.slice(0, line).map(([value, label]) => (
|
||||
<ListboxOption
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={disabledKeys.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
padding: '2px',
|
||||
marginTop: 5,
|
||||
borderTop: '1px solid ' + theme.menuBorder,
|
||||
}}
|
||||
/>
|
||||
{options.slice(line, options.length).map(([value, label]) => (
|
||||
<ListboxOption
|
||||
key={value}
|
||||
value={value}
|
||||
disabled={disabledKeys.includes(value)}
|
||||
>
|
||||
{label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxList>
|
||||
)}
|
||||
</ListboxPopover>
|
||||
</ListboxInput>
|
||||
items={options.map(item =>
|
||||
item === Menu.line
|
||||
? Menu.line
|
||||
: {
|
||||
name: item[0],
|
||||
text: item[1],
|
||||
disabled: disabledKeys.includes(item[0]),
|
||||
},
|
||||
)}
|
||||
getItemStyle={option => {
|
||||
if (targetOption && targetOption[0] === option.name) {
|
||||
return { fontWeight: 'bold' };
|
||||
}
|
||||
return {};
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,26 +4,29 @@ import { type RuleConditionEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { CondOpMenu } from './CondOpMenu';
|
||||
import { ConditionsOpMenu } from './ConditionsOpMenu';
|
||||
import { FilterExpression } from './FilterExpression';
|
||||
|
||||
type AppliedFiltersProps = {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
onUpdate: (
|
||||
filter: RuleConditionEntity,
|
||||
newFilter: RuleConditionEntity,
|
||||
) => void;
|
||||
onDelete: (filter: RuleConditionEntity) => void;
|
||||
conditionsOp: string;
|
||||
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
|
||||
onConditionsOpChange: (
|
||||
value: string,
|
||||
conditions: RuleConditionEntity[],
|
||||
) => void;
|
||||
};
|
||||
|
||||
export function AppliedFilters({
|
||||
filters,
|
||||
conditions,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
conditionsOp,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
}: AppliedFiltersProps) {
|
||||
return (
|
||||
<View
|
||||
@@ -33,12 +36,12 @@ export function AppliedFilters({
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<CondOpMenu
|
||||
<ConditionsOpMenu
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
filters={filters}
|
||||
onChange={onConditionsOpChange}
|
||||
conditions={conditions}
|
||||
/>
|
||||
{filters.map((filter: RuleConditionEntity, i: number) => (
|
||||
{conditions.map((filter: RuleConditionEntity, i: number) => (
|
||||
<FilterExpression
|
||||
key={i}
|
||||
customName={filter.customName}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,16 @@ import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
import { FieldSelect } from '../modals/EditRule';
|
||||
|
||||
export function CondOpMenu({
|
||||
export function ConditionsOpMenu({
|
||||
conditionsOp,
|
||||
onCondOpChange,
|
||||
filters,
|
||||
onChange,
|
||||
conditions,
|
||||
}: {
|
||||
conditionsOp: string;
|
||||
onCondOpChange: (value: string, filters: RuleConditionEntity[]) => void;
|
||||
filters: RuleConditionEntity[];
|
||||
onChange: (value: string, conditions: RuleConditionEntity[]) => void;
|
||||
conditions: RuleConditionEntity[];
|
||||
}) {
|
||||
return filters.length > 1 ? (
|
||||
return conditions.length > 1 ? (
|
||||
<Text style={{ color: theme.pageText, marginTop: 11, marginRight: 5 }}>
|
||||
<FieldSelect
|
||||
style={{ display: 'inline-flex' }}
|
||||
@@ -25,9 +25,7 @@ export function CondOpMenu({
|
||||
['or', 'any'],
|
||||
]}
|
||||
value={conditionsOp}
|
||||
onChange={(name: string, value: string) =>
|
||||
onCondOpChange(value, filters)
|
||||
}
|
||||
onChange={(name: string, value: string) => onChange(value, conditions)}
|
||||
/>
|
||||
of:
|
||||
</Text>
|
||||
@@ -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,10 +62,10 @@ export function FilterExpression({
|
||||
>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
disabled={customName != null}
|
||||
onClick={() => setEditing(true)}
|
||||
style={{ marginRight: -7 }}
|
||||
variant="bare"
|
||||
aria-label={`${displayField} ${displayOp} ${value} filter`}
|
||||
isDisabled={customName != null}
|
||||
onPress={() => setEditing(true)}
|
||||
>
|
||||
<div style={{ paddingBlock: 1, paddingLeft: 5, paddingRight: 2 }}>
|
||||
{customName ? (
|
||||
@@ -69,26 +73,29 @@ 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}
|
||||
inline={true}
|
||||
valueIsRaw={op === 'contains' || op === 'doesNotContain'}
|
||||
valueIsRaw={
|
||||
op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
<Button type="bare" onClick={onDelete} aria-label="Delete filter">
|
||||
<Button variant="bare" onPress={onDelete} aria-label="Delete filter">
|
||||
<SvgDelete
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: 5,
|
||||
marginLeft: 3,
|
||||
margin: 4,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
@@ -98,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';
|
||||
@@ -87,7 +87,6 @@ function ConfigureField({
|
||||
<Stack direction="row" align="flex-start">
|
||||
{field === 'amount' || field === 'date' ? (
|
||||
<Select
|
||||
bare
|
||||
options={
|
||||
field === 'amount'
|
||||
? [
|
||||
@@ -111,7 +110,6 @@ function ConfigureField({
|
||||
dispatch({ type: 'set-op', op: 'is' });
|
||||
}
|
||||
}}
|
||||
style={{ borderWidth: 1 }}
|
||||
/>
|
||||
) : (
|
||||
titleFirst(mapField(field))
|
||||
@@ -199,7 +197,8 @@ function ConfigureField({
|
||||
field={field}
|
||||
subfield={subfield}
|
||||
type={
|
||||
type === 'id' && (op === 'contains' || op === 'doesNotContain')
|
||||
type === 'id' &&
|
||||
(op === 'contains' || op === 'matches' || op === 'doesNotContain')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
@@ -220,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,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { type TransactionFilterEntity } from 'loot-core/types/models';
|
||||
import { type RuleConditionEntity } from 'loot-core/types/models/rule';
|
||||
|
||||
import { Stack } from '../common/Stack';
|
||||
@@ -12,17 +13,17 @@ import {
|
||||
} from './SavedFilterMenuButton';
|
||||
|
||||
export function FiltersStack({
|
||||
filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
onUpdateFilter,
|
||||
onDeleteFilter,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
filterId,
|
||||
filtersList,
|
||||
onCondOpChange,
|
||||
savedFilters,
|
||||
onConditionsOpChange,
|
||||
}: {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: string;
|
||||
onUpdateFilter: (
|
||||
filter: RuleConditionEntity,
|
||||
@@ -32,8 +33,8 @@ export function FiltersStack({
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
filterId: SavedFilter;
|
||||
filtersList: RuleConditionEntity[];
|
||||
onCondOpChange: () => void;
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
onConditionsOpChange: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
@@ -44,20 +45,20 @@ export function FiltersStack({
|
||||
align="flex-start"
|
||||
>
|
||||
<AppliedFilters
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
onUpdate={onUpdateFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
/>
|
||||
<View style={{ flex: 1 }} />
|
||||
<SavedFilterMenuButton
|
||||
filters={filters}
|
||||
conditions={conditions}
|
||||
conditionsOp={conditionsOp}
|
||||
filterId={filterId}
|
||||
onClearFilters={onClearFilters}
|
||||
onReloadSavedFilter={onReloadSavedFilter}
|
||||
filtersList={filtersList}
|
||||
savedFilters={savedFilters}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
|
||||
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';
|
||||
@@ -21,19 +22,19 @@ export type SavedFilter = {
|
||||
};
|
||||
|
||||
export function SavedFilterMenuButton({
|
||||
filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
filterId,
|
||||
onClearFilters,
|
||||
onReloadSavedFilter,
|
||||
filtersList,
|
||||
savedFilters,
|
||||
}: {
|
||||
filters: RuleConditionEntity[];
|
||||
conditions: RuleConditionEntity[];
|
||||
conditionsOp: string;
|
||||
filterId: SavedFilter;
|
||||
onClearFilters: () => void;
|
||||
onReloadSavedFilter: (savedFilter: SavedFilter, value?: string) => void;
|
||||
filtersList: RuleConditionEntity[];
|
||||
savedFilters: TransactionFilterEntity[];
|
||||
}) {
|
||||
const [nameOpen, setNameOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -64,7 +65,7 @@ export function SavedFilterMenuButton({
|
||||
setAdding(false);
|
||||
setMenuOpen(false);
|
||||
savedFilter = {
|
||||
conditions: filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
id: filterId.id,
|
||||
name: filterId.name,
|
||||
@@ -72,7 +73,7 @@ export function SavedFilterMenuButton({
|
||||
};
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: savedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -108,7 +109,7 @@ export function SavedFilterMenuButton({
|
||||
async function onAddUpdate() {
|
||||
if (adding) {
|
||||
const newSavedFilter = {
|
||||
conditions: filters,
|
||||
conditions,
|
||||
conditionsOp,
|
||||
name,
|
||||
status: 'saved',
|
||||
@@ -116,7 +117,7 @@ export function SavedFilterMenuButton({
|
||||
|
||||
const response = await sendCatch('filter-create', {
|
||||
state: newSavedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -142,7 +143,7 @@ export function SavedFilterMenuButton({
|
||||
|
||||
const response = await sendCatch('filter-update', {
|
||||
state: updatedFilter,
|
||||
filters: [...filtersList],
|
||||
filters: [...savedFilters],
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
@@ -157,12 +158,13 @@ export function SavedFilterMenuButton({
|
||||
|
||||
return (
|
||||
<View>
|
||||
{filters.length > 0 && (
|
||||
{conditions.length > 0 && (
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Saved filter menu"
|
||||
style={{ marginTop: 10 }}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@ export function updateFilterReducer(
|
||||
if (
|
||||
(type === 'id' || type === 'string') &&
|
||||
(action.op === 'contains' ||
|
||||
action.op === 'matches' ||
|
||||
action.op === 'is' ||
|
||||
action.op === 'doesNotContain' ||
|
||||
action.op === 'isNot')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { memo, useRef, useEffect } from 'react';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { AutoTextSize } from 'auto-text-size';
|
||||
import memoizeOne from 'memoize-one';
|
||||
@@ -9,6 +8,7 @@ import { collapseModals, pushModal } from 'loot-core/client/actions';
|
||||
import { rolloverBudget, reportBudget } from 'loot-core/src/client/queries';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { useLocalPref } from '../../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { SvgLogo } from '../../../icons/logo';
|
||||
@@ -16,13 +16,14 @@ import { SvgExpandArrow } from '../../../icons/v0';
|
||||
import {
|
||||
SvgArrowThinLeft,
|
||||
SvgArrowThinRight,
|
||||
SvgArrowThickRight,
|
||||
SvgCheveronRight,
|
||||
} from '../../../icons/v1';
|
||||
import { SvgViewShow } from '../../../icons/v2';
|
||||
import { useResponsive } from '../../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../../style';
|
||||
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
|
||||
import { makeAmountFullStyle, makeAmountGrey } from '../../budget/util';
|
||||
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
|
||||
import { Button } from '../../common/Button';
|
||||
import { Card } from '../../common/Card';
|
||||
import { Label } from '../../common/Label';
|
||||
@@ -329,11 +330,15 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
onBudgetAction,
|
||||
show3Cols,
|
||||
showBudgetedCol,
|
||||
setScrollPosition,
|
||||
onShowActivityWithScroll,
|
||||
}) {
|
||||
const opacity = blank ? 0 : 1;
|
||||
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const goalTemp = useSheetValue(goal);
|
||||
const goalValue = isGoalTemplatesEnabled ? goalTemp : null;
|
||||
const budgetedTemp = useSheetValue(budgeted);
|
||||
const budgetedValue = isGoalTemplatesEnabled ? budgetedTemp : null;
|
||||
|
||||
const [budgetType = 'rollover'] = useLocalPref('budgetType');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -373,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', {
|
||||
@@ -399,6 +404,10 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
|
||||
const listItemRef = useRef();
|
||||
const format = useFormat();
|
||||
const navigate = useNavigate();
|
||||
const onShowActivity = () => {
|
||||
navigate(`/categories/${category.id}?month=${month}`);
|
||||
};
|
||||
|
||||
const sidebarColumnWidth = getColumnWidth({ show3Cols, isSidebar: true });
|
||||
const columnWidth = getColumnWidth({ show3Cols });
|
||||
@@ -516,9 +525,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
binding={spent}
|
||||
getStyle={makeAmountGrey}
|
||||
type="financial"
|
||||
onClick={() => {
|
||||
onShowActivityWithScroll(category.id, month);
|
||||
}}
|
||||
onClick={onShowActivity}
|
||||
formatter={value => (
|
||||
<Button
|
||||
type="bare"
|
||||
@@ -575,9 +582,11 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
mode="oneline"
|
||||
style={{
|
||||
maxWidth: columnWidth,
|
||||
...makeAmountFullStyle(value, {
|
||||
zeroColor: theme.pillTextSubdued,
|
||||
}),
|
||||
...makeBalanceAmountStyle(
|
||||
value,
|
||||
goalValue,
|
||||
budgetedValue,
|
||||
),
|
||||
textAlign: 'right',
|
||||
fontSize: 12,
|
||||
}}
|
||||
@@ -586,6 +595,23 @@ const ExpenseCategory = memo(function ExpenseCategory({
|
||||
</AutoTextSize>
|
||||
</Button>
|
||||
)}
|
||||
carryoverIndicator={({ style }) => (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-3px',
|
||||
top: '-5px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: style?.color ?? theme.pillText,
|
||||
}}
|
||||
>
|
||||
<SvgArrowThickRight
|
||||
width={11}
|
||||
height={11}
|
||||
style={{ color: theme.pillBackgroundLight }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</View>
|
||||
@@ -1236,8 +1262,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
|
||||
showHiddenCategories,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
setScrollPosition,
|
||||
onShowActivityWithScroll,
|
||||
}) {
|
||||
function editable(content) {
|
||||
if (!editMode) {
|
||||
@@ -1350,8 +1374,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
|
||||
month={month}
|
||||
// onReorder={onReorderCategory}
|
||||
onBudgetAction={onBudgetAction}
|
||||
setScrollPosition={setScrollPosition}
|
||||
onShowActivityWithScroll={onShowActivityWithScroll}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1463,8 +1485,6 @@ function BudgetGroups({
|
||||
showBudgetedCol,
|
||||
show3Cols,
|
||||
showHiddenCategories,
|
||||
setScrollPosition,
|
||||
onShowActivityWithScroll,
|
||||
}) {
|
||||
const separateGroups = memoizeOne(groups => {
|
||||
return {
|
||||
@@ -1514,8 +1534,6 @@ function BudgetGroups({
|
||||
showHiddenCategories={showHiddenCategories}
|
||||
collapsed={collapsedGroupIds.includes(group.id)}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
setScrollPosition={setScrollPosition}
|
||||
onShowActivityWithScroll={onShowActivityWithScroll}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1566,8 +1584,6 @@ export function BudgetTable({
|
||||
}) {
|
||||
const { width } = useResponsive();
|
||||
const show3Cols = width >= 360;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// let editMode = false; // neuter editMode -- sorry, not rewriting drag-n-drop right now
|
||||
|
||||
@@ -1575,44 +1591,6 @@ export function BudgetTable({
|
||||
'mobile.showSpentColumn',
|
||||
);
|
||||
|
||||
const getCurrentScrollPosition = () => {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv');
|
||||
return scrollableDiv?.scrollTop || 0;
|
||||
};
|
||||
|
||||
const setScrollPosition = () => {
|
||||
const scrollPosition = getCurrentScrollPosition();
|
||||
|
||||
navigate('/budget', {
|
||||
replace: true,
|
||||
state: { scrollPosition }
|
||||
});
|
||||
};
|
||||
|
||||
const onShowActivityWithScroll = (categoryId, month) => {
|
||||
const scrollPosition = getCurrentScrollPosition();
|
||||
|
||||
navigate('/budget', {
|
||||
replace: true,
|
||||
state: { scrollPosition }
|
||||
});
|
||||
|
||||
navigate(`/categories/${categoryId}?month=${month}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const savedScrollPosition = location.state?.scrollPosition;
|
||||
|
||||
if (savedScrollPosition) {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv');
|
||||
if (scrollableDiv) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollableDiv.scrollTop = savedScrollPosition;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [location.state?.scrollPosition]);
|
||||
|
||||
function toggleSpentColumn() {
|
||||
setShowSpentColumnPref(!showSpentColumn);
|
||||
}
|
||||
@@ -1672,12 +1650,9 @@ export function BudgetTable({
|
||||
<PullToRefresh onRefresh={onRefresh}>
|
||||
<View
|
||||
data-testid="budget-table"
|
||||
id="scrollableDiv"
|
||||
style={{
|
||||
backgroundColor: theme.pageBackground,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<BudgetGroups
|
||||
@@ -1699,8 +1674,6 @@ export function BudgetTable({
|
||||
onReorderCategory={onReorderCategory}
|
||||
onReorderGroup={onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
setScrollPosition={setScrollPosition}
|
||||
onShowActivityWithScroll={onShowActivityWithScroll}
|
||||
/>
|
||||
</View>
|
||||
</PullToRefresh>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -362,7 +362,7 @@ const ChildTransactionEdit = forwardRef(
|
||||
<View>
|
||||
<FieldLabel title="Category" />
|
||||
<TapField
|
||||
style={{
|
||||
textStyle={{
|
||||
...((isOffBudget || isBudgetTransfer(transaction)) && {
|
||||
fontStyle: 'italic',
|
||||
color: theme.pageTextSubdued,
|
||||
@@ -490,6 +490,9 @@ const TransactionEditInner = memo(function TransactionEditInner({
|
||||
};
|
||||
|
||||
const getPrettyPayee = trans => {
|
||||
if (trans && trans.is_parent) {
|
||||
return 'Split';
|
||||
}
|
||||
const transPayee = trans && getPayee(trans);
|
||||
const transTransferAcct = trans && getTransferAcct(trans);
|
||||
return getDescriptionPretty(trans, transPayee, transTransferAcct);
|
||||
@@ -520,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) {
|
||||
@@ -636,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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -763,11 +765,17 @@ const TransactionEditInner = memo(function TransactionEditInner({
|
||||
<View>
|
||||
<FieldLabel title="Payee" />
|
||||
<TapField
|
||||
textStyle={{
|
||||
...(transaction.is_parent && {
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 300,
|
||||
}),
|
||||
}}
|
||||
value={getPrettyPayee(transaction)}
|
||||
disabled={
|
||||
editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'payee')
|
||||
}
|
||||
value={getPrettyPayee(transaction)}
|
||||
onClick={() => onEditField(transaction.id, 'payee')}
|
||||
data-testid="payee-field"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,14 @@ import { type CommonModalProps } from '../Modals';
|
||||
|
||||
type ConfirmTransactionEditProps = {
|
||||
modalProps: Partial<CommonModalProps>;
|
||||
onCancel?: () => void;
|
||||
onConfirm: () => void;
|
||||
confirmReason: string;
|
||||
};
|
||||
|
||||
export function ConfirmTransactionEdit({
|
||||
modalProps,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
confirmReason,
|
||||
}: ConfirmTransactionEditProps) {
|
||||
@@ -71,7 +73,13 @@ export function ConfirmTransactionEdit({
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button style={{ marginRight: 10 }} onClick={modalProps.onClose}>
|
||||
<Button
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => {
|
||||
modalProps.onClose();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -35,6 +41,7 @@ import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0';
|
||||
import { SvgInformationOutline } from '../../icons/v1';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Modal } from '../common/Modal';
|
||||
import { Select } from '../common/Select';
|
||||
import { Stack } from '../common/Stack';
|
||||
@@ -82,12 +89,13 @@ function getTransactionFields(conditions, actions) {
|
||||
|
||||
export function FieldSelect({ fields, style, value, onChange }) {
|
||||
return (
|
||||
<View style={{ color: theme.pageTextPositive, ...style }}>
|
||||
<View style={style}>
|
||||
<Select
|
||||
bare
|
||||
options={fields}
|
||||
value={value}
|
||||
onChange={value => onChange('field', value)}
|
||||
buttonStyle={{ color: theme.pageTextPositive }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -97,31 +105,36 @@ export function OpSelect({
|
||||
ops,
|
||||
type,
|
||||
style,
|
||||
wrapperStyle,
|
||||
value,
|
||||
formatOp = friendlyOp,
|
||||
onChange,
|
||||
}) {
|
||||
let line;
|
||||
// We don't support the `contains` operator for the id type for
|
||||
// rules yet
|
||||
if (type === 'id') {
|
||||
ops = ops.filter(op => op !== 'contains' && op !== 'doesNotContain');
|
||||
line = ops.length / 2;
|
||||
}
|
||||
if (type === 'string') {
|
||||
line = ops.length / 2;
|
||||
}
|
||||
const opOptions = useMemo(() => {
|
||||
const options = ops
|
||||
// We don't support the `contains`, `doesNotContain`, `matches` operators
|
||||
// for the id type rules yet
|
||||
// TODO: Add matches op support for payees, accounts, categories.
|
||||
.filter(op =>
|
||||
type === 'id'
|
||||
? !['contains', 'matches', 'doesNotContain'].includes(op)
|
||||
: true,
|
||||
)
|
||||
.map(op => [op, formatOp(op, type)]);
|
||||
|
||||
if (type === 'string' || type === 'id') {
|
||||
options.splice(Math.ceil(options.length / 2), 0, Menu.line);
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [ops, type]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
bare
|
||||
options={ops.map(op => [op, formatOp(op, type)])}
|
||||
options={opOptions}
|
||||
value={value}
|
||||
onChange={value => onChange('op', value)}
|
||||
line={line}
|
||||
style={{ minHeight: '1px', ...style }}
|
||||
wrapperStyle={wrapperStyle}
|
||||
buttonStyle={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -487,9 +586,8 @@ function SelectField({
|
||||
]),
|
||||
]}
|
||||
value={value === null ? 'choose-field' : value}
|
||||
style={{ width: '100%' }}
|
||||
wrapperStyle={style}
|
||||
onChange={value => onChange(value)}
|
||||
onChange={onChange}
|
||||
buttonStyle={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -520,8 +618,7 @@ function DateFormatSelect({
|
||||
f.label.replace(/ /g, delimiter),
|
||||
])}
|
||||
value={parseDateFormat || ''}
|
||||
onChange={value => onChange(value)}
|
||||
style={{ width: '100%' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -627,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 (
|
||||
@@ -740,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');
|
||||
@@ -756,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
|
||||
@@ -785,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);
|
||||
|
||||
@@ -796,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') {
|
||||
@@ -806,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);
|
||||
@@ -829,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,6 +971,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
const amt = e;
|
||||
if (!amt || amt.match(/^\d{1,}(\.\d{0,4})?$/)) {
|
||||
setMultiplierAmount(amt);
|
||||
runImportPreview();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,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() {
|
||||
@@ -914,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 =
|
||||
@@ -943,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,
|
||||
@@ -996,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' },
|
||||
@@ -1003,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' } });
|
||||
}
|
||||
@@ -1040,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}
|
||||
@@ -1072,6 +1443,8 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
flipAmount={flipAmount}
|
||||
multiplierAmount={multiplierAmount}
|
||||
categories={categories.list}
|
||||
onCheckTransaction={onCheckTransaction}
|
||||
reconcile={reconcile}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -1096,7 +1469,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
)}
|
||||
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginTop: 25 }}>
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<FieldMappings
|
||||
transactions={transactions}
|
||||
onChange={onUpdateFields}
|
||||
@@ -1130,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"
|
||||
@@ -1153,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={{
|
||||
@@ -1221,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>
|
||||
@@ -1247,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>
|
||||
@@ -1255,7 +1637,10 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
disabled={splitMode || flipAmount}
|
||||
onToggle={() => setInOutMode(!inOutMode)}
|
||||
onToggle={() => {
|
||||
setInOutMode(!inOutMode);
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeText={setOutValue}
|
||||
/>
|
||||
</>
|
||||
@@ -1266,6 +1651,7 @@ export function ImportTransactions({ modalProps, options }) {
|
||||
onToggle={() => {
|
||||
setMultiplierEnabled(!multiplierEnabled);
|
||||
setMultiplierAmount('');
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeAmount={onMultiplierChange}
|
||||
/>
|
||||
@@ -1281,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,6 +1,7 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme } from '../../style';
|
||||
@@ -21,6 +22,7 @@ export function PayeeAutocompleteModal({
|
||||
}: PayeeAutocompleteModalProps) {
|
||||
const payees = usePayees() || [];
|
||||
const accounts = useAccounts() || [];
|
||||
const navigate = useNavigate();
|
||||
|
||||
const _onClose = () => {
|
||||
modalProps.onClose();
|
||||
@@ -32,6 +34,8 @@ export function PayeeAutocompleteModal({
|
||||
containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } },
|
||||
};
|
||||
|
||||
const onManagePayees = () => navigate('/payees');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
@@ -56,20 +60,19 @@ export function PayeeAutocompleteModal({
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<PayeeAutocomplete
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
focused={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
onClose={_onClose}
|
||||
showManagePayees={false}
|
||||
showMakeTransfer={!isNarrowWidth}
|
||||
{...defaultAutocompleteProps}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
)}
|
||||
<PayeeAutocomplete
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
focused={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
onClose={_onClose}
|
||||
onManagePayees={onManagePayees}
|
||||
showManagePayees={!isNarrowWidth}
|
||||
showMakeTransfer={!isNarrowWidth}
|
||||
{...defaultAutocompleteProps}
|
||||
{...autocompleteProps}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { reportBudget } from 'loot-core/client/queries';
|
||||
|
||||
import { useCategory } from '../../hooks/useCategory';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { BalanceWithCarryover } from '../budget/BalanceWithCarryover';
|
||||
import {
|
||||
BalanceWithCarryover,
|
||||
DefaultCarryoverIndicator,
|
||||
} from '../budget/BalanceWithCarryover';
|
||||
import { BalanceMenu } from '../budget/report/BalanceMenu';
|
||||
import { Modal, ModalTitle } from '../common/Modal';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -63,11 +66,21 @@ export function ReportBalanceMenuModal({
|
||||
textAlign: 'center',
|
||||
...styles.veryLargeText,
|
||||
}}
|
||||
carryoverStyle={{ right: -20, width: 15, height: 15 }}
|
||||
carryover={reportBudget.catCarryover(categoryId)}
|
||||
balance={reportBudget.catBalance(categoryId)}
|
||||
goal={reportBudget.catGoal(categoryId)}
|
||||
budgeted={reportBudget.catBudgeted(categoryId)}
|
||||
carryoverIndicator={({ style }) =>
|
||||
DefaultCarryoverIndicator({
|
||||
style: {
|
||||
width: 15,
|
||||
height: 15,
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
...style,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<BalanceMenu
|
||||
|
||||
@@ -4,7 +4,10 @@ import { rolloverBudget } from 'loot-core/client/queries';
|
||||
|
||||
import { useCategory } from '../../hooks/useCategory';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { BalanceWithCarryover } from '../budget/BalanceWithCarryover';
|
||||
import {
|
||||
BalanceWithCarryover,
|
||||
DefaultCarryoverIndicator,
|
||||
} from '../budget/BalanceWithCarryover';
|
||||
import { BalanceMenu } from '../budget/rollover/BalanceMenu';
|
||||
import { Modal, ModalTitle } from '../common/Modal';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -65,11 +68,21 @@ export function RolloverBalanceMenuModal({
|
||||
textAlign: 'center',
|
||||
...styles.veryLargeText,
|
||||
}}
|
||||
carryoverStyle={{ right: -20, width: 15, height: 15 }}
|
||||
carryover={rolloverBudget.catCarryover(categoryId)}
|
||||
balance={rolloverBudget.catBalance(categoryId)}
|
||||
goal={rolloverBudget.catGoal(categoryId)}
|
||||
budgeted={rolloverBudget.catBudgeted(categoryId)}
|
||||
carryoverIndicator={({ style }) =>
|
||||
DefaultCarryoverIndicator({
|
||||
style: {
|
||||
width: 15,
|
||||
height: 15,
|
||||
display: 'inline-flex',
|
||||
position: 'relative',
|
||||
...style,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<BalanceMenu
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useStableCallback } from '../../hooks/useStableCallback';
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Search } from '../common/Search';
|
||||
import { View } from '../common/View';
|
||||
@@ -236,10 +236,10 @@ export const ManagePayees = forwardRef(
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
style={{ marginRight: 10 }}
|
||||
disabled={buttonsDisabled}
|
||||
onClick={() => setMenuOpen(true)}
|
||||
isDisabled={buttonsDisabled}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
>
|
||||
{buttonsDisabled
|
||||
? 'No payees selected'
|
||||
@@ -273,9 +273,9 @@ export const ManagePayees = forwardRef(
|
||||
{(orphanedOnly ||
|
||||
(orphanedPayees && orphanedPayees.length > 0)) && (
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
style={{ marginRight: 10 }}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
setOrphanedOnly(!orphanedOnly);
|
||||
applyFilter(filter);
|
||||
tableNavigator.onEdit(null);
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Header({
|
||||
onApply,
|
||||
onUpdateFilter,
|
||||
onDeleteFilter,
|
||||
onCondOpChange,
|
||||
onConditionsOpChange,
|
||||
headerPrefixItems,
|
||||
children,
|
||||
}) {
|
||||
@@ -151,11 +151,11 @@ export function Header({
|
||||
align="flex-start"
|
||||
>
|
||||
<AppliedFilters
|
||||
filters={filters}
|
||||
conditions={filters}
|
||||
onUpdate={onUpdateFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
conditionsOp={conditionsOp}
|
||||
onCondOpChange={onCondOpChange}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||