Compare commits
34 Commits
jfdoming/a
...
matiss/crd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8393a65d7a | ||
|
|
6b351eafc7 | ||
|
|
43fba254b5 | ||
|
|
6e8ac07846 | ||
|
|
99682268cc | ||
|
|
749aee4f44 | ||
|
|
531b1a1914 | ||
|
|
f08490052f | ||
|
|
145868f9da | ||
|
|
44fc959ed8 | ||
|
|
d787d0ce43 | ||
|
|
9513c1e160 | ||
|
|
2c3e2a34fd | ||
|
|
78d533c800 | ||
|
|
49f6b21f2c | ||
|
|
9f05207fe8 | ||
|
|
8366c442a2 | ||
|
|
e661951753 | ||
|
|
fc5e836a02 | ||
|
|
4b73fd7e45 | ||
|
|
c593bda145 | ||
|
|
1b86bba2cd | ||
|
|
6c2c96e826 | ||
|
|
6298f6a324 | ||
|
|
1afe7c9a1e | ||
|
|
24279264da | ||
|
|
4a5ee9c2dc | ||
|
|
a8eb204ce7 | ||
|
|
f68e4fbb2a | ||
|
|
dd3b1144d1 | ||
|
|
ff0f5bdb35 | ||
|
|
11ce29e7fd | ||
|
|
d58c9a9a07 | ||
|
|
598e3ec9d8 |
@@ -1,6 +1,6 @@
|
||||
issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: false
|
||||
enabled: true
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: false
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,4 +1,4 @@
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://actualbudget.org/docs/contributing/#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
|
||||
|
||||
## Description
|
||||
|
||||
|
||||
4
.github/actions/docs-spelling/expect.txt
vendored
@@ -61,6 +61,7 @@ Dockerfiles
|
||||
Dominguez
|
||||
DUSSDEDDXXX
|
||||
DUSSELDORF
|
||||
ecf
|
||||
EDATE
|
||||
ENTERCARD
|
||||
Entra
|
||||
@@ -140,8 +141,6 @@ pluggyai
|
||||
Poste
|
||||
PPABPLPK
|
||||
prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
Qatari
|
||||
QNTOFRP
|
||||
@@ -172,7 +171,6 @@ SWEDBANK
|
||||
SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
|
||||
9
.github/workflows/e2e-test.yml
vendored
@@ -46,13 +46,12 @@ jobs:
|
||||
# via ConfigurationPage.createTestFile()) is still rendered in a
|
||||
# production build. Without it, e2e tests would time out waiting for
|
||||
# a button that was tree-shaken out.
|
||||
# --skip-translations keeps VRT screenshots deterministic by rendering
|
||||
# source-code English instead of upstream Weblate en.json (which can
|
||||
# drift between snapshot capture and test runs).
|
||||
env:
|
||||
REACT_APP_NETLIFY: 'true'
|
||||
run: |
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
run: yarn build:browser --skip-translations
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
3
.gitignore
vendored
@@ -42,6 +42,9 @@ bundle.desktop.js.map
|
||||
bundle.mobile.js
|
||||
bundle.mobile.js.map
|
||||
|
||||
# Python virtualenv (Electron CI provisions one at the repo root for setuptools)
|
||||
.venv/
|
||||
|
||||
# Yarn
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly"
|
||||
"FS": "readonly",
|
||||
"__APP_VERSION__": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
@@ -370,7 +371,8 @@
|
||||
"files": ["**/*.test.{js,ts,jsx,tsx}", "packages/docs/**/*"],
|
||||
"rules": {
|
||||
"actual/no-untranslated-strings": "off",
|
||||
"actual/prefer-logger-over-console": "off"
|
||||
"actual/prefer-logger-over-console": "off",
|
||||
"typescript/unbound-method": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,21 +4,30 @@ ROOT=`dirname $0`
|
||||
|
||||
cd "$ROOT/.."
|
||||
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
SKIP_TRANSLATIONS=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-translations)
|
||||
SKIP_TRANSLATIONS=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$SKIP_TRANSLATIONS" = false ]; then
|
||||
echo "Updating translations..."
|
||||
if ! [ -d packages/desktop-client/locale ]; then
|
||||
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git checkout .
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
fi
|
||||
pushd packages/desktop-client/locale > /dev/null
|
||||
git checkout .
|
||||
git pull
|
||||
popd > /dev/null
|
||||
packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
echo "packages/desktop-client/build"
|
||||
lage build:browser --to=@actual-app/web
|
||||
|
||||
@@ -57,8 +57,7 @@ yarn workspace @actual-app/core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
# required for running the sync-server server
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
|
||||
@@ -25,6 +25,14 @@ module.exports = {
|
||||
outputGlob: BUILD_OUTPUT_GLOBS,
|
||||
},
|
||||
},
|
||||
// Not cached: the script stages files into public/ and build-stats/ that
|
||||
// fall outside BUILD_OUTPUT_GLOBS, so a cache hit would skip the side
|
||||
// effects.
|
||||
'build:browser': {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^build'],
|
||||
cache: false,
|
||||
},
|
||||
},
|
||||
cacheOptions: {
|
||||
cacheStorageConfig: {
|
||||
|
||||
@@ -24,18 +24,16 @@
|
||||
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:docs": "yarn workspace docs start",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:browser": "npm-run-all --parallel 'start:browser-*' 'start:service-plugins'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build": "lage build",
|
||||
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
@@ -54,7 +52,7 @@
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core && ./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
|
||||
"rebuild-node": "yarn workspace @actual-app/core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
|
||||
@@ -6,6 +6,11 @@ import { vi } from 'vitest';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
declare global {
|
||||
var IS_TESTING: boolean;
|
||||
var currentMonth: string | null;
|
||||
}
|
||||
|
||||
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
|
||||
// Mock the fs so path constants point at loot-core package root where migrations live.
|
||||
vi.mock(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.4.0",
|
||||
"version": "26.5.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"files": [
|
||||
"@types",
|
||||
"dist"
|
||||
"dist",
|
||||
"!@types/**/*.test.d.ts",
|
||||
"!@types/**/*.test.d.ts.map"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.test.ts",
|
||||
"*.config.ts",
|
||||
"*.config.mts"
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/cli",
|
||||
"version": "26.4.0",
|
||||
"version": "26.5.0",
|
||||
"description": "CLI for Actual Budget",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
"description": "CRDT layer of Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"!dist/**/*.test.d.ts",
|
||||
"!dist/**/*.test.d.ts.map",
|
||||
"!dist/**/*.spec.d.ts",
|
||||
"!dist/**/*.spec.d.ts.map"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
@@ -21,12 +21,14 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "vite build",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
|
||||
"build": "yarn run build:node && tsgo -b",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
|
||||
1
packages/desktop-client/.gitignore
vendored
@@ -8,6 +8,7 @@ coverage
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
.playwright-cli
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/sh -ex
|
||||
|
||||
ROOT=`dirname $0`
|
||||
cd "$ROOT/.."
|
||||
|
||||
echo "Building the browser..."
|
||||
|
||||
rm -fr build
|
||||
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
|
||||
yarn build --mode=browser
|
||||
|
||||
rm -fr build-stats
|
||||
mkdir build-stats
|
||||
mv build/kcab/stats.json build-stats/loot-core-stats.json
|
||||
mv ./stats.json build-stats/web-stats.json
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 81 KiB |
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect as baseExpect } from '@playwright/test';
|
||||
import type { Browser, Locator } from '@playwright/test';
|
||||
import type { Browser, Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Disable CSS transitions and animations globally in e2e (non-VRT) runs.
|
||||
@@ -51,7 +51,7 @@ export const test = process.env.VRT
|
||||
});
|
||||
|
||||
export const expect = baseExpect.extend({
|
||||
async toMatchThemeScreenshots(locator: Locator) {
|
||||
async toMatchThemeScreenshots(target: Locator | Page) {
|
||||
// Disable screenshot assertions in regular e2e tests;
|
||||
// only enable them when doing VRT tests
|
||||
if (!process.env.VRT) {
|
||||
@@ -62,38 +62,33 @@ export const expect = baseExpect.extend({
|
||||
}
|
||||
|
||||
const config = {
|
||||
mask: [locator.locator('[data-vrt-mask="true"]')],
|
||||
mask: [target.locator('[data-vrt-mask="true"]')],
|
||||
maxDiffPixels: 5,
|
||||
};
|
||||
|
||||
// Get the data-theme attribute from page.
|
||||
// If there is a page() function, it means that the locator
|
||||
// is not a page object but a locator object.
|
||||
const dataThemeLocator =
|
||||
typeof locator.page === 'function'
|
||||
? locator.page().locator('[data-theme]')
|
||||
: locator.locator('[data-theme]');
|
||||
const page: Page = 'page' in target ? target.page() : target;
|
||||
const dataThemeLocator = page.locator('[data-theme]');
|
||||
|
||||
// Check lightmode
|
||||
await locator.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await page.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'auto');
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch to darkmode and check
|
||||
await locator.evaluate(() => window.Actual.setTheme('dark'));
|
||||
await page.evaluate(() => window.Actual.setTheme('dark'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute('data-theme', 'dark');
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch to midnight theme and check
|
||||
await locator.evaluate(() => window.Actual.setTheme('midnight'));
|
||||
await page.evaluate(() => window.Actual.setTheme('midnight'));
|
||||
await baseExpect(dataThemeLocator).toHaveAttribute(
|
||||
'data-theme',
|
||||
'midnight',
|
||||
);
|
||||
await baseExpect(locator).toHaveScreenshot(config);
|
||||
await baseExpect(target).toHaveScreenshot(config);
|
||||
|
||||
// Switch back to lightmode
|
||||
await locator.evaluate(() => window.Actual.setTheme('auto'));
|
||||
await page.evaluate(() => window.Actual.setTheme('auto'));
|
||||
return {
|
||||
message: () => 'pass',
|
||||
pass: true,
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 21 KiB |
9
packages/desktop-client/e2e/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"noEmit": true,
|
||||
"types": ["@playwright/test", "node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../../loot-core/typings/window.ts"]
|
||||
}
|
||||
@@ -86,7 +86,10 @@
|
||||
'Arial',
|
||||
sans-serif
|
||||
);
|
||||
font-feature-settings: 'ss01', 'ss04';
|
||||
font-feature-settings:
|
||||
'ss01',
|
||||
'ss04',
|
||||
'calt' 0;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "26.4.0",
|
||||
"version": "26.5.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,12 +34,19 @@
|
||||
"#polyfills": "./src/polyfills.ts",
|
||||
"#components/forms": "./src/components/forms/index.tsx",
|
||||
"#components/banksync": "./src/components/banksync/index.tsx",
|
||||
"#components/banksync/bankSyncUtils": "./src/components/banksync/bankSyncUtils.ts",
|
||||
"#components/banksync/BuiltInProviders": "./src/components/banksync/BuiltInProviders.tsx",
|
||||
"#components/banksync/useBuiltInBankSyncProviders": "./src/components/banksync/useBuiltInBankSyncProviders.ts",
|
||||
"#components/banksync/useBankSyncAccountSettings": "./src/components/banksync/useBankSyncAccountSettings.ts",
|
||||
"#components/budget": "./src/components/budget/index.tsx",
|
||||
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",
|
||||
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
|
||||
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
|
||||
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
|
||||
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
|
||||
"#components/budget/goals/reducer": "./src/components/budget/goals/reducer.ts",
|
||||
"#components/budget/goals/useBudgetAutomationCategories": "./src/components/budget/goals/useBudgetAutomationCategories.ts",
|
||||
"#components/budget/goals/validateAutomation": "./src/components/budget/goals/validateAutomation.ts",
|
||||
"#components/budget/util": "./src/components/budget/util.ts",
|
||||
"#components/codemirror/autocompleteTabAccept": "./src/components/codemirror/autocompleteTabAccept.ts",
|
||||
"#components/mobile/utils": "./src/components/mobile/utils.ts",
|
||||
@@ -104,7 +111,7 @@
|
||||
"start:browser": "cross-env ./bin/watch-browser",
|
||||
"watch": "cross-env BROWSER=none yarn start",
|
||||
"build": "vite build",
|
||||
"build:browser": "cross-env ./bin/build-browser",
|
||||
"build:browser": "vite build --mode=browser",
|
||||
"generate:i18n": "i18next",
|
||||
"test": "vitest --run",
|
||||
"validate:theme-catalog": "node --experimental-strip-types bin/validate-theme-catalog.mts",
|
||||
@@ -164,6 +171,7 @@
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
"plugins-service": "workspace:*",
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -27,6 +27,13 @@ let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
function terminateLocalBackendWorker() {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
@@ -43,9 +50,22 @@ class WorkerBridge {
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
this._isInitialized = false;
|
||||
this._currentBudgetId = null;
|
||||
this._wasHidden = document.visibilityState === 'hidden';
|
||||
|
||||
this._onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
this._wasHidden = true;
|
||||
} else if (this._wasHidden) {
|
||||
this._wasHidden = false;
|
||||
this._resumeAssociation();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
document.addEventListener('visibilitychange', this._onVisibilityChange);
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
@@ -109,10 +129,7 @@ class WorkerBridge {
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
@@ -126,6 +143,7 @@ class WorkerBridge {
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
this._applyRole(msg.role, msg.budgetId ?? null);
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
@@ -146,13 +164,47 @@ class WorkerBridge {
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
if (msg && msg.type === 'push' && msg.name === 'show-budgets') {
|
||||
this._applyRole('UNASSIGNED', null);
|
||||
}
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
markInitialized() {
|
||||
this._isInitialized = true;
|
||||
}
|
||||
|
||||
_normalizeBudgetId(budgetId) {
|
||||
if (
|
||||
typeof budgetId === 'string' &&
|
||||
budgetId.length > 0 &&
|
||||
!budgetId.startsWith('__')
|
||||
) {
|
||||
return budgetId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_applyRole(role, budgetId) {
|
||||
this._currentBudgetId = this._normalizeBudgetId(budgetId);
|
||||
|
||||
if (role !== 'LEADER') {
|
||||
terminateLocalBackendWorker();
|
||||
}
|
||||
}
|
||||
|
||||
_resumeAssociation() {
|
||||
if (!this._isInitialized) {
|
||||
return;
|
||||
}
|
||||
this._sharedPort.postMessage({
|
||||
type: '__resume-tab',
|
||||
budgetId: this._currentBudgetId,
|
||||
});
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
terminateLocalBackendWorker();
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
@@ -238,10 +290,12 @@ function createBackendWorker() {
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
worker.markInitialized();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
const notifyTabClosing = () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
};
|
||||
window.addEventListener('beforeunload', notifyTabClosing);
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
@@ -281,10 +335,17 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
// Skip SW registration in dev so stale cached assets don't override edits
|
||||
// between page loads. Plugin code that needs a SW can register one itself.
|
||||
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
|
||||
// SW lifecycle to swap the page — fall back to a plain reload so callers
|
||||
// don't hang on the never-resolving promise inside applyAppUpdate.
|
||||
const updateSW = IS_DEV
|
||||
? () => window.location.reload()
|
||||
: registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
|
||||
@@ -243,8 +243,8 @@ function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
|
||||
) : (
|
||||
<AnimatedRefresh animating={syncing} />
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled' ? t('Disabled') : null}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : null}>
|
||||
{syncState === 'disabled' ? ` ${t('Disabled')}` : null}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -44,13 +44,19 @@ function makeSchedule(
|
||||
} satisfies ScheduleEntity;
|
||||
}
|
||||
|
||||
function mockedSchedules(schedules: ScheduleEntity[]) {
|
||||
return {
|
||||
isLoading: false,
|
||||
schedules,
|
||||
statuses: new Map(),
|
||||
statusLabels: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SelectedBalance – normal transactions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(mockedSchedules([]));
|
||||
});
|
||||
|
||||
test('shows balance for selected normal transactions', () => {
|
||||
@@ -93,10 +99,9 @@ describe('SelectedBalance – preview (scheduled) transactions', () => {
|
||||
vi.mocked(useSelectedItems).mockReturnValue(
|
||||
new Set([`preview/${scheduleId}/2026-03-24`]),
|
||||
);
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(
|
||||
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
@@ -116,10 +121,9 @@ describe('SelectedBalance – preview (scheduled) transactions', () => {
|
||||
const selectedItems = new Set([previewId1, previewId2]);
|
||||
|
||||
vi.mocked(useSelectedItems).mockReturnValue(selectedItems);
|
||||
vi.mocked(useCachedSchedules).mockReturnValue({
|
||||
isLoading: false,
|
||||
schedules: [makeSchedule(scheduleId, -5000, 'account-1')],
|
||||
});
|
||||
vi.mocked(useCachedSchedules).mockReturnValue(
|
||||
mockedSchedules([makeSchedule(scheduleId, -5000, 'account-1')]),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestProviders>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Warning } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
|
||||
import type { BuiltInBankSyncProviderState } from './useBuiltInBankSyncProviders';
|
||||
|
||||
type BuiltInProvidersProps = {
|
||||
providers: BuiltInBankSyncProviderState[];
|
||||
syncServerStatus: 'offline' | 'no-server' | 'online';
|
||||
showPermissionWarning: boolean;
|
||||
providersNeedingConfiguration: BuiltInBankSyncProviderState[];
|
||||
};
|
||||
|
||||
export function BuiltInProviders({
|
||||
providers,
|
||||
syncServerStatus,
|
||||
showPermissionWarning,
|
||||
providersNeedingConfiguration,
|
||||
}: BuiltInProvidersProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ fontSize: 20, fontWeight: 600 }}>
|
||||
<Trans>Providers</Trans>
|
||||
</Text>
|
||||
<Paragraph style={{ fontSize: 15, color: theme.pageTextSubdued }}>
|
||||
<Trans>
|
||||
Set up a bank sync provider, then link new accounts or connect an
|
||||
existing Actual account.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
</View>
|
||||
|
||||
{syncServerStatus !== 'online' ? (
|
||||
<View
|
||||
style={{
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
backgroundColor: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<Button isDisabled style={{ padding: '10px 0', fontSize: 15 }}>
|
||||
<Trans>Set up bank sync</Trans>
|
||||
</Button>
|
||||
<Paragraph style={{ fontSize: 15, marginTop: 10 }}>
|
||||
<Trans>
|
||||
Connect to an Actual server to set up{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/advanced/bank-sync"
|
||||
linkColor="muted"
|
||||
>
|
||||
automatic syncing
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{providers.map(provider => (
|
||||
<View
|
||||
key={provider.id}
|
||||
data-testid={`bank-sync-provider-${provider.id}`}
|
||||
style={{
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
backgroundColor: theme.tableBackground,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
gap: 6,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 17, fontWeight: 600 }}>
|
||||
{provider.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: provider.isConfigured
|
||||
? theme.noticeTextDark
|
||||
: theme.pageTextSubdued,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{provider.isConfigured ? (
|
||||
<Trans>Configured</Trans>
|
||||
) : (
|
||||
<Trans>Not configured</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{provider.isConfigured && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('{{provider}} menu', {
|
||||
provider: provider.displayName,
|
||||
})}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
void provider.onReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset {{provider}} credentials', {
|
||||
provider: provider.displayName,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
isDisabled={!provider.canConfigure}
|
||||
onPress={() => provider.onConfigure()}
|
||||
>
|
||||
{provider.isConfigured ? (
|
||||
<Trans>Edit setup</Trans>
|
||||
) : (
|
||||
<Trans>Set up</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isDisabled={!provider.isConfigured}
|
||||
isLoading={provider.isLoading}
|
||||
onPress={() => provider.onLink()}
|
||||
>
|
||||
<Trans>Link bank account</Trans>
|
||||
</ButtonWithLoading>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showPermissionWarning && (
|
||||
<Warning>
|
||||
<Trans>
|
||||
You don't have the required permissions to configure bank sync
|
||||
providers. Please contact an Admin to configure
|
||||
</Trans>{' '}
|
||||
{providersNeedingConfiguration
|
||||
.map(provider => provider.displayName)
|
||||
.join(' or ')}
|
||||
.
|
||||
</Warning>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { generateAccount } from '@actual-app/core/mocks';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSyncSourceReadable, groupBankSyncAccounts } from './bankSyncUtils';
|
||||
|
||||
describe('bankSyncUtils', () => {
|
||||
it('groups open accounts by provider and leaves unlinked last', () => {
|
||||
const goCardlessAccount = generateAccount('GoCardless', true, false);
|
||||
const pluggyAccount = {
|
||||
...generateAccount('Pluggy', true, false),
|
||||
account_sync_source: 'pluggyai' as const,
|
||||
};
|
||||
const simpleFinAccount = {
|
||||
...generateAccount('SimpleFIN', true, false),
|
||||
account_sync_source: 'simpleFin' as const,
|
||||
};
|
||||
const unlinkedAccount = generateAccount('Manual', false, false);
|
||||
const closedAccount = {
|
||||
...generateAccount('Closed', true, false),
|
||||
closed: 1 as const,
|
||||
};
|
||||
|
||||
const groupedAccounts = groupBankSyncAccounts([
|
||||
unlinkedAccount,
|
||||
simpleFinAccount,
|
||||
closedAccount,
|
||||
pluggyAccount,
|
||||
goCardlessAccount,
|
||||
]);
|
||||
|
||||
expect(Object.keys(groupedAccounts)).toEqual([
|
||||
'goCardless',
|
||||
'pluggyai',
|
||||
'simpleFin',
|
||||
'unlinked',
|
||||
]);
|
||||
expect(groupedAccounts.goCardless).toEqual([goCardlessAccount]);
|
||||
expect(groupedAccounts.pluggyai).toEqual([pluggyAccount]);
|
||||
expect(groupedAccounts.simpleFin).toEqual([simpleFinAccount]);
|
||||
expect(groupedAccounts.unlinked).toEqual([unlinkedAccount]);
|
||||
});
|
||||
|
||||
it('returns stable readable provider labels', () => {
|
||||
const readable = getSyncSourceReadable(
|
||||
(key: string) => `translated:${key}`,
|
||||
);
|
||||
|
||||
expect(readable.goCardless).toBe('GoCardless');
|
||||
expect(readable.simpleFin).toBe('SimpleFIN');
|
||||
expect(readable.pluggyai).toBe('Pluggy.ai');
|
||||
expect(readable.unlinked).toBe('translated:Unlinked');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
export type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
export type GroupedBankSyncAccounts = Partial<
|
||||
Record<SyncProviders, AccountEntity[]>
|
||||
>;
|
||||
|
||||
export const BUILT_IN_BANK_SYNC_PROVIDERS = [
|
||||
'goCardless',
|
||||
'simpleFin',
|
||||
'pluggyai',
|
||||
] as const satisfies BankSyncProviders[];
|
||||
|
||||
const SYNC_PROVIDER_KEYS = [
|
||||
...BUILT_IN_BANK_SYNC_PROVIDERS,
|
||||
'unlinked',
|
||||
] as const satisfies readonly SyncProviders[];
|
||||
|
||||
const syncProviderKeysSet = new Set<string>(SYNC_PROVIDER_KEYS);
|
||||
|
||||
function isSyncProvider(value: string): value is SyncProviders {
|
||||
return syncProviderKeysSet.has(value);
|
||||
}
|
||||
|
||||
export function getSyncSourceReadable(
|
||||
translate: (key: string) => string,
|
||||
): Record<SyncProviders, string> {
|
||||
return {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
unlinked: translate('Unlinked'),
|
||||
};
|
||||
}
|
||||
|
||||
export function groupBankSyncAccounts(
|
||||
accounts: AccountEntity[],
|
||||
): GroupedBankSyncAccounts {
|
||||
const groupedAccounts: GroupedBankSyncAccounts = {};
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.closed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const syncSource = account.account_sync_source ?? 'unlinked';
|
||||
const existingAccounts = groupedAccounts[syncSource];
|
||||
|
||||
if (existingAccounts) {
|
||||
existingAccounts.push(account);
|
||||
} else {
|
||||
groupedAccounts[syncSource] = [account];
|
||||
}
|
||||
}
|
||||
|
||||
const sortedEntries = Object.entries(groupedAccounts)
|
||||
.filter(
|
||||
(entry): entry is [SyncProviders, AccountEntity[]] =>
|
||||
isSyncProvider(entry[0]) && entry[1] != null,
|
||||
)
|
||||
.sort(([keyA], [keyB]) => {
|
||||
if (keyA === 'unlinked') return 1;
|
||||
if (keyB === 'unlinked') return -1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
const sortedAccounts: GroupedBankSyncAccounts = {};
|
||||
for (const [syncSource, providerAccounts] of sortedEntries) {
|
||||
sortedAccounts[syncSource] = providerAccounts;
|
||||
}
|
||||
|
||||
return sortedAccounts;
|
||||
}
|
||||
|
||||
export function getGroupedBankSyncEntries(
|
||||
groupedAccounts: GroupedBankSyncAccounts,
|
||||
): Array<[SyncProviders, AccountEntity[]]> {
|
||||
return Object.entries(groupedAccounts).filter(
|
||||
(entry): entry is [SyncProviders, AccountEntity[]] =>
|
||||
isSyncProvider(entry[0]) && entry[1] != null,
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,7 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
|
||||
import { Page } from '#components/Page';
|
||||
@@ -19,63 +16,44 @@ import { useDispatch } from '#redux';
|
||||
|
||||
import { AccountsHeader } from './AccountsHeader';
|
||||
import { AccountsList } from './AccountsList';
|
||||
|
||||
type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
|
||||
const useSyncSourceReadable = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const syncSourceReadable: Record<SyncProviders, string> = {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
unlinked: t('Unlinked'),
|
||||
};
|
||||
|
||||
return { syncSourceReadable };
|
||||
};
|
||||
import {
|
||||
getGroupedBankSyncEntries,
|
||||
getSyncSourceReadable,
|
||||
groupBankSyncAccounts,
|
||||
} from './bankSyncUtils';
|
||||
import { BuiltInProviders } from './BuiltInProviders';
|
||||
import { useBuiltInBankSyncProviders } from './useBuiltInBankSyncProviders';
|
||||
|
||||
export function BankSync() {
|
||||
const { t } = useTranslation();
|
||||
const [floatingSidebar] = useGlobalPref('floatingSidebar');
|
||||
|
||||
const { syncSourceReadable } = useSyncSourceReadable();
|
||||
|
||||
const { data: accounts = [] } = useAccounts();
|
||||
const dispatch = useDispatch();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
|
||||
const {
|
||||
providers,
|
||||
syncServerStatus,
|
||||
showPermissionWarning,
|
||||
providersNeedingConfiguration,
|
||||
} = useBuiltInBankSyncProviders();
|
||||
|
||||
const [hoveredAccount, setHoveredAccount] = useState<
|
||||
AccountEntity['id'] | null
|
||||
>(null);
|
||||
|
||||
const groupedAccounts = useMemo(() => {
|
||||
const unsorted = accounts
|
||||
.filter(a => !a.closed)
|
||||
.reduce(
|
||||
(acc, a) => {
|
||||
const syncSource = a.account_sync_source ?? 'unlinked';
|
||||
acc[syncSource] = acc[syncSource] || [];
|
||||
acc[syncSource].push(a);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
|
||||
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
|
||||
if (keyA === 'unlinked') return 1;
|
||||
if (keyB === 'unlinked') return -1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
return sortedKeys.reduce(
|
||||
(sorted, key) => {
|
||||
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
|
||||
return sorted;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
}, [accounts]);
|
||||
const groupedAccounts = useMemo(
|
||||
() => groupBankSyncAccounts(accounts),
|
||||
[accounts],
|
||||
);
|
||||
const groupedAccountEntries = useMemo(
|
||||
() => getGroupedBankSyncEntries(groupedAccounts),
|
||||
[groupedAccounts],
|
||||
);
|
||||
const openAccounts = useMemo(
|
||||
() => accounts.filter(account => !account.closed),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const onAction = async (account: AccountEntity, action: 'link' | 'edit') => {
|
||||
switch (action) {
|
||||
@@ -119,22 +97,30 @@ export function BankSync() {
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<View style={{ marginTop: '1em' }}>
|
||||
{accounts.length === 0 && (
|
||||
<View style={{ marginTop: '1em', gap: 24 }}>
|
||||
<BuiltInProviders
|
||||
providers={providers}
|
||||
syncServerStatus={syncServerStatus}
|
||||
showPermissionWarning={showPermissionWarning}
|
||||
providersNeedingConfiguration={providersNeedingConfiguration}
|
||||
/>
|
||||
|
||||
{openAccounts.length === 0 && (
|
||||
<Text style={{ fontSize: '1.1rem' }}>
|
||||
<Trans>
|
||||
To use the bank syncing features, you must first add an account.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{Object.entries(groupedAccounts).map(([syncProvider, accounts]) => {
|
||||
|
||||
{groupedAccountEntries.map(([syncProvider, accounts]) => {
|
||||
return (
|
||||
<View key={syncProvider} style={{ minHeight: 'initial' }}>
|
||||
{Object.keys(groupedAccounts).length > 1 && (
|
||||
{groupedAccountEntries.length > 1 && (
|
||||
<Text
|
||||
style={{ fontWeight: 500, fontSize: 20, margin: '.5em 0' }}
|
||||
>
|
||||
{syncSourceReadable[syncProvider as SyncProviders]}
|
||||
{syncSourceReadable[syncProvider]}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.tableContainer}>
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
import type { SyncServerSimpleFinAccount } from '@actual-app/core/types/models/simplefin';
|
||||
|
||||
import { useAuth } from '#auth/AuthProvider';
|
||||
import { Permissions } from '#auth/types';
|
||||
import { useMultiuserEnabled } from '#components/ServerContext';
|
||||
import { authorizeBank } from '#gocardless';
|
||||
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
|
||||
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
|
||||
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
|
||||
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import { addNotification } from '#notifications/notificationsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
import { BUILT_IN_BANK_SYNC_PROVIDERS } from './bankSyncUtils';
|
||||
|
||||
type ProviderAction = () => void | Promise<void>;
|
||||
|
||||
type SimpleFinAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
balance: number;
|
||||
org: {
|
||||
name: string;
|
||||
domain: string;
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PluggyAiAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'BANK' | string;
|
||||
taxNumber: string;
|
||||
owner: string;
|
||||
balance: number;
|
||||
bankData: {
|
||||
automaticallyInvestedBalance: number;
|
||||
closingBalance: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type BuiltInBankSyncProviderState = {
|
||||
id: BankSyncProviders;
|
||||
displayName: string;
|
||||
description: string;
|
||||
isConfigured: boolean;
|
||||
canConfigure: boolean;
|
||||
isLoading?: boolean;
|
||||
onConfigure: ProviderAction;
|
||||
onLink: ProviderAction;
|
||||
onReset: ProviderAction;
|
||||
};
|
||||
|
||||
type SecretSetResponse = {
|
||||
error?: string;
|
||||
error_code?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type UseBuiltInBankSyncProvidersOptions = {
|
||||
upgradingAccountId?: AccountEntity['id'];
|
||||
};
|
||||
|
||||
async function ensureSuccessResponse(
|
||||
response: SecretSetResponse,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
if (response.error_code) {
|
||||
throw new Error(response.reason || response.error_code);
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.reason || response.error || fallbackMessage);
|
||||
}
|
||||
}
|
||||
|
||||
export function useBuiltInBankSyncProviders({
|
||||
upgradingAccountId,
|
||||
}: UseBuiltInBankSyncProvidersOptions = {}) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const { hasPermission } = useAuth();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
const canConfigureProviders =
|
||||
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
|
||||
|
||||
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
|
||||
useState(false);
|
||||
|
||||
const { configuredGoCardless } = useGoCardlessStatus();
|
||||
const { configuredSimpleFin } = useSimpleFinStatus();
|
||||
const { configuredPluggyAi } = usePluggyAiStatus();
|
||||
|
||||
useEffect(() => {
|
||||
setIsGoCardlessSetupComplete(configuredGoCardless);
|
||||
}, [configuredGoCardless]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSimpleFinSetupComplete(configuredSimpleFin);
|
||||
}, [configuredSimpleFin]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPluggyAiSetupComplete(configuredPluggyAi);
|
||||
}, [configuredPluggyAi]);
|
||||
|
||||
const onGoCardlessInit = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'gocardless-init',
|
||||
options: {
|
||||
onSuccess: () => setIsGoCardlessSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const onSimpleFinInit = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const onPluggyAiInit = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => setIsPluggyAiSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const notifyResetFailure = useCallback(
|
||||
(providerName: string, error: unknown) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Failed to reset {{provider}}', {
|
||||
provider: providerName,
|
||||
}),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, t],
|
||||
);
|
||||
|
||||
const onGoCardlessReset = useCallback(async () => {
|
||||
try {
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear GoCardless secret ID',
|
||||
);
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear GoCardless secret key',
|
||||
);
|
||||
setIsGoCardlessSetupComplete(false);
|
||||
} catch (error) {
|
||||
notifyResetFailure('GoCardless', error);
|
||||
}
|
||||
}, [notifyResetFailure]);
|
||||
|
||||
const onSimpleFinReset = useCallback(async () => {
|
||||
try {
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear SimpleFIN token',
|
||||
);
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_accessKey',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear SimpleFIN access key',
|
||||
);
|
||||
setIsSimpleFinSetupComplete(false);
|
||||
} catch (error) {
|
||||
notifyResetFailure('SimpleFIN', error);
|
||||
}
|
||||
}, [notifyResetFailure]);
|
||||
|
||||
const onPluggyAiReset = useCallback(async () => {
|
||||
try {
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear Pluggy.ai client ID',
|
||||
);
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_clientSecret',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear Pluggy.ai client secret',
|
||||
);
|
||||
await ensureSuccessResponse(
|
||||
await send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: null,
|
||||
}),
|
||||
'Failed to clear Pluggy.ai item IDs',
|
||||
);
|
||||
setIsPluggyAiSetupComplete(false);
|
||||
} catch (error) {
|
||||
notifyResetFailure('Pluggy.ai', error);
|
||||
}
|
||||
}, [notifyResetFailure]);
|
||||
|
||||
const onConnectGoCardless = useCallback(() => {
|
||||
if (!isGoCardlessSetupComplete) {
|
||||
onGoCardlessInit();
|
||||
return;
|
||||
}
|
||||
|
||||
void authorizeBank(dispatch, upgradingAccountId);
|
||||
}, [
|
||||
dispatch,
|
||||
isGoCardlessSetupComplete,
|
||||
onGoCardlessInit,
|
||||
upgradingAccountId,
|
||||
]);
|
||||
|
||||
const onConnectSimpleFin = useCallback(async () => {
|
||||
if (!isSimpleFinSetupComplete) {
|
||||
onSimpleFinInit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingSimpleFinAccounts) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSimpleFinAccounts(true);
|
||||
|
||||
try {
|
||||
const results = await send('simplefin-accounts');
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
if ('error' in results && results.error) {
|
||||
throw new Error(results.reason || results.error);
|
||||
}
|
||||
|
||||
const externalAccounts: SyncServerSimpleFinAccount[] = (
|
||||
(results.accounts ?? []) as SimpleFinAccount[]
|
||||
).map(oldAccount => ({
|
||||
account_id: oldAccount.id,
|
||||
name: oldAccount.name,
|
||||
institution: oldAccount.org.name,
|
||||
orgDomain: oldAccount.org.domain,
|
||||
orgId: oldAccount.org.id,
|
||||
balance: oldAccount.balance,
|
||||
}));
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts,
|
||||
syncSource: 'simpleFin',
|
||||
upgradingAccountId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
onSimpleFinInit();
|
||||
} finally {
|
||||
setLoadingSimpleFinAccounts(false);
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
isSimpleFinSetupComplete,
|
||||
loadingSimpleFinAccounts,
|
||||
onSimpleFinInit,
|
||||
upgradingAccountId,
|
||||
]);
|
||||
|
||||
const onConnectPluggyAi = useCallback(async () => {
|
||||
if (!isPluggyAiSetupComplete) {
|
||||
onPluggyAiInit();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await send('pluggyai-accounts');
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
if ('error' in results) {
|
||||
throw new Error(results.error);
|
||||
}
|
||||
|
||||
const externalAccounts = (results.accounts as PluggyAiAccount[]).map(
|
||||
oldAccount => ({
|
||||
account_id: oldAccount.id,
|
||||
name: `${oldAccount.name.trim()} - ${
|
||||
oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner
|
||||
}`,
|
||||
institution: oldAccount.name,
|
||||
orgDomain: null,
|
||||
orgId: oldAccount.id,
|
||||
balance:
|
||||
oldAccount.type === 'BANK'
|
||||
? oldAccount.bankData.automaticallyInvestedBalance +
|
||||
oldAccount.bankData.closingBalance
|
||||
: oldAccount.balance,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts,
|
||||
syncSource: 'pluggyai',
|
||||
upgradingAccountId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error when trying to contact Pluggy.ai'),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
onPluggyAiInit();
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
isPluggyAiSetupComplete,
|
||||
onPluggyAiInit,
|
||||
t,
|
||||
upgradingAccountId,
|
||||
]);
|
||||
|
||||
const configuredProviders = {
|
||||
goCardless: Boolean(isGoCardlessSetupComplete),
|
||||
simpleFin: Boolean(isSimpleFinSetupComplete),
|
||||
pluggyai: Boolean(isPluggyAiSetupComplete),
|
||||
} satisfies Record<BankSyncProviders, boolean>;
|
||||
|
||||
const providers = useMemo<BuiltInBankSyncProviderState[]>(
|
||||
() =>
|
||||
BUILT_IN_BANK_SYNC_PROVIDERS.map(providerId => {
|
||||
if (providerId === 'goCardless') {
|
||||
return {
|
||||
id: providerId,
|
||||
displayName: 'GoCardless',
|
||||
description: t(
|
||||
'Link a European bank account to automatically download transactions.',
|
||||
),
|
||||
isConfigured: configuredProviders.goCardless,
|
||||
canConfigure: canConfigureProviders,
|
||||
onConfigure: onGoCardlessInit,
|
||||
onLink: onConnectGoCardless,
|
||||
onReset: onGoCardlessReset,
|
||||
};
|
||||
}
|
||||
|
||||
if (providerId === 'simpleFin') {
|
||||
return {
|
||||
id: providerId,
|
||||
displayName: 'SimpleFIN',
|
||||
description: t(
|
||||
'Link a North American bank account to automatically download transactions.',
|
||||
),
|
||||
isConfigured: configuredProviders.simpleFin,
|
||||
canConfigure: canConfigureProviders,
|
||||
isLoading: loadingSimpleFinAccounts,
|
||||
onConfigure: onSimpleFinInit,
|
||||
onLink: onConnectSimpleFin,
|
||||
onReset: onSimpleFinReset,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
displayName: 'Pluggy.ai',
|
||||
description: t(
|
||||
'Link a Brazilian bank account to automatically download transactions.',
|
||||
),
|
||||
isConfigured: configuredProviders.pluggyai,
|
||||
canConfigure: canConfigureProviders,
|
||||
onConfigure: onPluggyAiInit,
|
||||
onLink: onConnectPluggyAi,
|
||||
onReset: onPluggyAiReset,
|
||||
};
|
||||
}),
|
||||
[
|
||||
canConfigureProviders,
|
||||
configuredProviders.goCardless,
|
||||
configuredProviders.pluggyai,
|
||||
configuredProviders.simpleFin,
|
||||
loadingSimpleFinAccounts,
|
||||
onConnectGoCardless,
|
||||
onConnectPluggyAi,
|
||||
onConnectSimpleFin,
|
||||
onGoCardlessInit,
|
||||
onGoCardlessReset,
|
||||
onPluggyAiInit,
|
||||
onPluggyAiReset,
|
||||
onSimpleFinInit,
|
||||
onSimpleFinReset,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const providersNeedingConfiguration = providers.filter(
|
||||
provider => !provider.isConfigured,
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
syncServerStatus,
|
||||
canConfigureProviders,
|
||||
showPermissionWarning:
|
||||
providersNeedingConfiguration.length > 0 && !canConfigureProviders,
|
||||
providersNeedingConfiguration,
|
||||
};
|
||||
}
|
||||
@@ -512,7 +512,10 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
placement="bottom end"
|
||||
isOpen={balanceMenuOpen}
|
||||
onOpenChange={() => setBalanceMenuOpen(false)}
|
||||
style={{ margin: 1 }}
|
||||
style={{
|
||||
margin: 1,
|
||||
minWidth: 190,
|
||||
}}
|
||||
isNonModal
|
||||
{...balancePosition}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type {
|
||||
CategoryGroupEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
import type { Action } from './actions';
|
||||
import type { ReducerState } from './constants';
|
||||
import { BySaveAutomation } from './editor/BySaveAutomation';
|
||||
import { FixedAutomation } from './editor/FixedAutomation';
|
||||
import { HistoricalAutomation } from './editor/HistoricalAutomation';
|
||||
import { LimitAutomation } from './editor/LimitAutomation';
|
||||
import { PercentageAutomation } from './editor/PercentageAutomation';
|
||||
import { RefillAutomation } from './editor/RefillAutomation';
|
||||
import { RemainderAutomation } from './editor/RemainderAutomation';
|
||||
import { ScheduleAutomation } from './editor/ScheduleAutomation';
|
||||
|
||||
type ActiveEditorProps = {
|
||||
state: ReducerState;
|
||||
dispatch: (action: Action) => void;
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
hasLimitAutomation: boolean;
|
||||
onAddLimitAutomation: () => void;
|
||||
};
|
||||
|
||||
export function ActiveEditor({
|
||||
state,
|
||||
dispatch,
|
||||
schedules,
|
||||
categories,
|
||||
hasLimitAutomation,
|
||||
onAddLimitAutomation,
|
||||
}: ActiveEditorProps) {
|
||||
switch (state.displayType) {
|
||||
case 'limit':
|
||||
return <LimitAutomation template={state.template} dispatch={dispatch} />;
|
||||
case 'refill':
|
||||
return (
|
||||
<RefillAutomation
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={onAddLimitAutomation}
|
||||
/>
|
||||
);
|
||||
case 'fixed':
|
||||
return <FixedAutomation template={state.template} dispatch={dispatch} />;
|
||||
case 'schedule':
|
||||
return (
|
||||
<ScheduleAutomation
|
||||
schedules={schedules}
|
||||
template={state.template}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
);
|
||||
case 'percentage':
|
||||
return (
|
||||
<PercentageAutomation
|
||||
dispatch={dispatch}
|
||||
template={state.template}
|
||||
categories={categories}
|
||||
/>
|
||||
);
|
||||
case 'historical':
|
||||
return (
|
||||
<HistoricalAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
case 'by':
|
||||
return <BySaveAutomation template={state.template} dispatch={dispatch} />;
|
||||
case 'remainder':
|
||||
return (
|
||||
<RemainderAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
default:
|
||||
state satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,17 @@ import { FormField, FormLabel, FormTextLabel } from '#components/forms';
|
||||
|
||||
import { setType } from './actions';
|
||||
import type { Action } from './actions';
|
||||
import { displayTemplateTypes } from './constants';
|
||||
import type { ReducerState } from './constants';
|
||||
import { displayTemplateTypes } from './constants';
|
||||
import { getDisplayTemplateMeta } from './displayTemplateMeta';
|
||||
import { BySaveAutomation } from './editor/BySaveAutomation';
|
||||
import { FixedAutomation } from './editor/FixedAutomation';
|
||||
import { HistoricalAutomation } from './editor/HistoricalAutomation';
|
||||
import { LimitAutomation } from './editor/LimitAutomation';
|
||||
import { PercentageAutomation } from './editor/PercentageAutomation';
|
||||
import { RefillAutomation } from './editor/RefillAutomation';
|
||||
import { RemainderAutomation } from './editor/RemainderAutomation';
|
||||
import { ScheduleAutomation } from './editor/ScheduleAutomation';
|
||||
import { WeekAutomation } from './editor/WeekAutomation';
|
||||
|
||||
type BudgetAutomationEditorProps = {
|
||||
inline: boolean;
|
||||
@@ -50,7 +53,7 @@ const displayTypeToDescription = {
|
||||
automation.
|
||||
</Trans>
|
||||
),
|
||||
week: (
|
||||
fixed: (
|
||||
<Trans>
|
||||
Add a fixed amount to this category for each week in the month. For
|
||||
example, $100 per week would be $400 per month in a 4-week month.
|
||||
@@ -80,6 +83,18 @@ const displayTypeToDescription = {
|
||||
to account for seasonal changes.
|
||||
</Trans>
|
||||
),
|
||||
by: (
|
||||
<Trans>
|
||||
Spread a target amount across the months between now and a target date.
|
||||
Useful for annual goals or saving toward a one-off expense.
|
||||
</Trans>
|
||||
),
|
||||
remainder: (
|
||||
<Trans>
|
||||
Split any remaining To Budget across categories using this automation.
|
||||
Higher weights take a larger share of the leftover funds.
|
||||
</Trans>
|
||||
),
|
||||
};
|
||||
|
||||
export function BudgetAutomationEditor({
|
||||
@@ -108,9 +123,9 @@ export function BudgetAutomationEditor({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'week':
|
||||
case 'fixed':
|
||||
automationEditor = (
|
||||
<WeekAutomation template={state.template} dispatch={dispatch} />
|
||||
<FixedAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
break;
|
||||
case 'schedule':
|
||||
@@ -136,6 +151,16 @@ export function BudgetAutomationEditor({
|
||||
<HistoricalAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
break;
|
||||
case 'by':
|
||||
automationEditor = (
|
||||
<BySaveAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
break;
|
||||
case 'remainder':
|
||||
automationEditor = (
|
||||
<RemainderAutomation template={state.template} dispatch={dispatch} />
|
||||
);
|
||||
break;
|
||||
default:
|
||||
state satisfies never;
|
||||
automationEditor = (
|
||||
@@ -165,7 +190,10 @@ export function BudgetAutomationEditor({
|
||||
<InitialFocus>
|
||||
<Select
|
||||
id="type-field"
|
||||
options={displayTemplateTypes}
|
||||
options={displayTemplateTypes.map(type => [
|
||||
type,
|
||||
getDisplayTemplateMeta(type).label,
|
||||
])}
|
||||
defaultLabel={t('Select an option')}
|
||||
value={state.displayType}
|
||||
onChange={type => type && dispatch(setType(type))}
|
||||
|
||||
@@ -14,12 +14,14 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import type { ReducerState } from './constants';
|
||||
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
|
||||
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
|
||||
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
|
||||
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
|
||||
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
|
||||
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
|
||||
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
|
||||
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
|
||||
import { WeekAutomationReadOnly } from './editor/WeekAutomationReadOnly';
|
||||
|
||||
type BudgetAutomationReadOnlyProps = {
|
||||
state: ReducerState;
|
||||
@@ -52,8 +54,10 @@ export function BudgetAutomationReadOnly({
|
||||
case 'refill':
|
||||
automationReadOnly = <RefillAutomationReadOnly />;
|
||||
break;
|
||||
case 'week':
|
||||
automationReadOnly = <WeekAutomationReadOnly template={state.template} />;
|
||||
case 'fixed':
|
||||
automationReadOnly = (
|
||||
<FixedAutomationReadOnly template={state.template} />
|
||||
);
|
||||
break;
|
||||
case 'schedule':
|
||||
automationReadOnly = (
|
||||
@@ -73,7 +77,18 @@ export function BudgetAutomationReadOnly({
|
||||
<HistoricalAutomationReadOnly template={state.template} />
|
||||
);
|
||||
break;
|
||||
case 'by':
|
||||
automationReadOnly = (
|
||||
<BySaveAutomationReadOnly template={state.template} />
|
||||
);
|
||||
break;
|
||||
case 'remainder':
|
||||
automationReadOnly = (
|
||||
<RemainderAutomationReadOnly template={state.template} />
|
||||
);
|
||||
break;
|
||||
default:
|
||||
state satisfies never;
|
||||
automationReadOnly = (
|
||||
<Text>
|
||||
<Trans>Unrecognized automation type.</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -8,7 +8,9 @@ import { theme } from '@actual-app/components/theme';
|
||||
import type { CategoryEntity } from '@actual-app/core/types/models';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { MonthsContext } from '#components/budget/MonthsContext';
|
||||
import { useFeatureFlag } from '#hooks/useFeatureFlag';
|
||||
import { useSyncedPref } from '#hooks/useSyncedPref';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
@@ -30,15 +32,24 @@ export function CategoryAutomationButton({
|
||||
}: CategoryAutomationButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const monthsContext = useContext(MonthsContext);
|
||||
const month = monthsContext?.months?.[0];
|
||||
|
||||
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
|
||||
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
|
||||
const hasAutomations = !!category.goal_def?.length;
|
||||
|
||||
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Income categories don't accept templates in envelope budgets (only the
|
||||
// tracking budget runs templates against income categories).
|
||||
if (category.is_income && budgetType !== 'tracking') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
@@ -59,7 +70,7 @@ export function CategoryAutomationButton({
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-automations-edit',
|
||||
options: { categoryId: category.id },
|
||||
options: { categoryId: category.id, month },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import type { TransObjectLiteral } from '@actual-app/core/types/util';
|
||||
|
||||
import { BySaveAutomationReadOnly } from './editor/BySaveAutomationReadOnly';
|
||||
import { FixedAutomationReadOnly } from './editor/FixedAutomationReadOnly';
|
||||
import { HistoricalAutomationReadOnly } from './editor/HistoricalAutomationReadOnly';
|
||||
import { LimitAutomationReadOnly } from './editor/LimitAutomationReadOnly';
|
||||
import { PercentageAutomationReadOnly } from './editor/PercentageAutomationReadOnly';
|
||||
import { RefillAutomationReadOnly } from './editor/RefillAutomationReadOnly';
|
||||
import { RemainderAutomationReadOnly } from './editor/RemainderAutomationReadOnly';
|
||||
import { ScheduleAutomationReadOnly } from './editor/ScheduleAutomationReadOnly';
|
||||
|
||||
type TemplateSentenceProps = {
|
||||
template: Template;
|
||||
categoryNameMap: Record<string, string>;
|
||||
};
|
||||
|
||||
export function TemplateSentence({
|
||||
template,
|
||||
categoryNameMap,
|
||||
}: TemplateSentenceProps) {
|
||||
switch (template.type) {
|
||||
case 'limit':
|
||||
return <LimitAutomationReadOnly template={template} />;
|
||||
case 'refill':
|
||||
return <RefillAutomationReadOnly />;
|
||||
case 'periodic':
|
||||
return <FixedAutomationReadOnly template={template} />;
|
||||
case 'schedule':
|
||||
return <ScheduleAutomationReadOnly template={template} />;
|
||||
case 'percentage':
|
||||
return (
|
||||
<PercentageAutomationReadOnly
|
||||
template={template}
|
||||
categoryNameMap={categoryNameMap}
|
||||
/>
|
||||
);
|
||||
case 'average':
|
||||
case 'copy':
|
||||
return <HistoricalAutomationReadOnly template={template} />;
|
||||
case 'by':
|
||||
return <BySaveAutomationReadOnly template={template} />;
|
||||
case 'remainder':
|
||||
return <RemainderAutomationReadOnly template={template} />;
|
||||
case 'simple':
|
||||
case 'spend':
|
||||
case 'goal':
|
||||
case 'error': {
|
||||
const type = template.type;
|
||||
return (
|
||||
<Trans>
|
||||
Unsupported template type: {{ type } satisfies TransObjectLiteral}
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
default:
|
||||
template satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
addMonths,
|
||||
dayFromDate,
|
||||
firstDayOfMonth,
|
||||
monthFromDate,
|
||||
} from '@actual-app/core/shared/months';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
|
||||
import type { DisplayTemplateType } from './constants';
|
||||
import { DEFAULT_PRIORITY } from './reducer';
|
||||
|
||||
export type AutomationEntry = {
|
||||
id: string;
|
||||
template: Template;
|
||||
displayType: DisplayTemplateType;
|
||||
};
|
||||
|
||||
export function createAutomationEntry(
|
||||
template: Template,
|
||||
displayType: DisplayTemplateType,
|
||||
): AutomationEntry {
|
||||
return {
|
||||
id: uniqueId('automation-'),
|
||||
template,
|
||||
displayType,
|
||||
};
|
||||
}
|
||||
|
||||
export type AutomationExample = {
|
||||
displayType: DisplayTemplateType;
|
||||
create: () => AutomationEntry;
|
||||
};
|
||||
|
||||
export function getAutomationExamples(): AutomationExample[] {
|
||||
return [
|
||||
{
|
||||
displayType: 'fixed',
|
||||
create: () =>
|
||||
createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'periodic',
|
||||
amount: 100,
|
||||
period: { period: 'month', amount: 1 },
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
'fixed',
|
||||
),
|
||||
},
|
||||
{
|
||||
displayType: 'by',
|
||||
create: () =>
|
||||
createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'by',
|
||||
amount: 1200,
|
||||
// Always 12 months out so users in late-year months don't get a
|
||||
// target that's already passed.
|
||||
month: addMonths(monthFromDate(new Date()), 12),
|
||||
annual: true,
|
||||
repeat: 1,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
'by',
|
||||
),
|
||||
},
|
||||
{
|
||||
displayType: 'schedule',
|
||||
create: () =>
|
||||
createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'schedule',
|
||||
name: '',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
'schedule',
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { useLocale } from '#hooks/useLocale';
|
||||
|
||||
import { formatMonthLabel } from './formatMonthLabel';
|
||||
import type {
|
||||
AutomationErrorKind,
|
||||
GlobalConflictKind,
|
||||
} from './validateAutomation';
|
||||
|
||||
export function AutomationErrorTitle({
|
||||
error,
|
||||
}: {
|
||||
error: AutomationErrorKind;
|
||||
}) {
|
||||
switch (error.kind) {
|
||||
case 'schedule-not-found':
|
||||
return <Trans>Schedule not found</Trans>;
|
||||
case 'refill-no-cap':
|
||||
return <Trans>Refill needs a balance cap</Trans>;
|
||||
case 'percentage-out-of-range':
|
||||
return <Trans>Percentage out of range</Trans>;
|
||||
case 'percentage-no-source':
|
||||
return <Trans>Source category missing</Trans>;
|
||||
case 'by-no-month':
|
||||
return <Trans>Target month missing</Trans>;
|
||||
case 'by-target-past':
|
||||
return <Trans>Target is in the past</Trans>;
|
||||
case 'percentage-source-not-found':
|
||||
return <Trans>Source category not recognised</Trans>;
|
||||
default:
|
||||
error satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AutomationErrorShort({
|
||||
error,
|
||||
}: {
|
||||
error: AutomationErrorKind;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
switch (error.kind) {
|
||||
case 'schedule-not-found':
|
||||
return error.name ? (
|
||||
<Trans>No schedule named “{{ name: error.name }}”</Trans>
|
||||
) : (
|
||||
<Trans>Pick a schedule</Trans>
|
||||
);
|
||||
case 'refill-no-cap':
|
||||
return <Trans>Add a balance cap above</Trans>;
|
||||
case 'percentage-out-of-range':
|
||||
return (
|
||||
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
|
||||
);
|
||||
case 'percentage-no-source':
|
||||
return <Trans>Pick a source category</Trans>;
|
||||
case 'by-no-month':
|
||||
return <Trans>Pick a target month</Trans>;
|
||||
case 'by-target-past':
|
||||
return (
|
||||
<Trans>
|
||||
{{ month: formatMonthLabel(error.month, locale) }} has already passed
|
||||
</Trans>
|
||||
);
|
||||
case 'percentage-source-not-found':
|
||||
return <Trans>Pick a valid income category</Trans>;
|
||||
default:
|
||||
error satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AutomationErrorDetail({
|
||||
error,
|
||||
}: {
|
||||
error: AutomationErrorKind;
|
||||
}) {
|
||||
switch (error.kind) {
|
||||
case 'schedule-not-found':
|
||||
return (
|
||||
<Trans>
|
||||
Pick an existing schedule, or create one in Schedules. This automation
|
||||
can’t run until it’s linked to a schedule.
|
||||
</Trans>
|
||||
);
|
||||
case 'refill-no-cap':
|
||||
return (
|
||||
<Trans>
|
||||
Refill automations must have a “Balance cap” automation
|
||||
added to use as the target.
|
||||
</Trans>
|
||||
);
|
||||
case 'percentage-out-of-range':
|
||||
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
|
||||
case 'percentage-no-source':
|
||||
return (
|
||||
<Trans>
|
||||
Percentage automations need a source category to calculate against.
|
||||
</Trans>
|
||||
);
|
||||
case 'by-no-month':
|
||||
return (
|
||||
<Trans>
|
||||
Goals by date need a target month. Pick when you want this fully
|
||||
funded.
|
||||
</Trans>
|
||||
);
|
||||
case 'by-target-past':
|
||||
return (
|
||||
<Trans>
|
||||
Pick a future month, or switch to a recurring annual goal to keep
|
||||
saving.
|
||||
</Trans>
|
||||
);
|
||||
case 'percentage-source-not-found':
|
||||
return (
|
||||
<Trans>
|
||||
The selected source “{{ source: error.source }}” is not a
|
||||
known income category.
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
error satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalConflictTitle({
|
||||
conflict,
|
||||
}: {
|
||||
conflict: GlobalConflictKind;
|
||||
}) {
|
||||
switch (conflict.kind) {
|
||||
case 'over-income':
|
||||
return <Trans>Automations will demand more than income</Trans>;
|
||||
case 'percent-over-100':
|
||||
return (
|
||||
<Trans>
|
||||
Percent automations total {{ total: Math.round(conflict.total) }}% of
|
||||
income
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
conflict satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlobalConflictDetail({
|
||||
conflict,
|
||||
}: {
|
||||
conflict: GlobalConflictKind;
|
||||
}) {
|
||||
const format = useFormat();
|
||||
switch (conflict.kind) {
|
||||
case 'over-income':
|
||||
return (
|
||||
<Trans>
|
||||
This month’s automations ask for around{' '}
|
||||
{{ total: format(conflict.total, 'financial') }} but only{' '}
|
||||
{{ income: format(conflict.income, 'financial') }} is available to
|
||||
budget. Lower amounts or switch one to “Whatever is left”.
|
||||
</Trans>
|
||||
);
|
||||
case 'percent-over-100':
|
||||
return (
|
||||
<Trans>
|
||||
Your percent automations add up to more than 100% and will be capped
|
||||
at 100%.
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
conflict satisfies never;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import type {
|
||||
AverageTemplate,
|
||||
ByTemplate,
|
||||
CopyTemplate,
|
||||
LimitTemplate,
|
||||
PercentageTemplate,
|
||||
PeriodicTemplate,
|
||||
RefillTemplate,
|
||||
RemainderTemplate,
|
||||
ScheduleTemplate,
|
||||
} from '@actual-app/core/types/models/templates';
|
||||
|
||||
export const displayTemplateTypes = [
|
||||
['limit', 'Balance limit'] as const,
|
||||
['refill', 'Refill'] as const,
|
||||
['week', 'Fixed (weekly)'] as const,
|
||||
['schedule', 'Existing schedule'] as const,
|
||||
['percentage', 'Percent of category'] as const,
|
||||
['historical', 'Copy past budgets'] as const,
|
||||
];
|
||||
'fixed',
|
||||
'schedule',
|
||||
'by',
|
||||
'percentage',
|
||||
'historical',
|
||||
'limit',
|
||||
'refill',
|
||||
'remainder',
|
||||
] as const;
|
||||
|
||||
export type DisplayTemplateType = (typeof displayTemplateTypes)[number][0];
|
||||
export type DisplayTemplateType = (typeof displayTemplateTypes)[number];
|
||||
|
||||
export type ReducerState =
|
||||
| {
|
||||
@@ -30,7 +34,7 @@ export type ReducerState =
|
||||
}
|
||||
| {
|
||||
template: PeriodicTemplate;
|
||||
displayType: 'week';
|
||||
displayType: 'fixed';
|
||||
}
|
||||
| {
|
||||
template: ScheduleTemplate;
|
||||
@@ -43,4 +47,12 @@ export type ReducerState =
|
||||
| {
|
||||
template: CopyTemplate | AverageTemplate;
|
||||
displayType: 'historical';
|
||||
}
|
||||
| {
|
||||
template: ByTemplate;
|
||||
displayType: 'by';
|
||||
}
|
||||
| {
|
||||
template: RemainderTemplate;
|
||||
displayType: 'remainder';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import {
|
||||
SvgChartPie,
|
||||
SvgEquals,
|
||||
SvgMoneyBag,
|
||||
SvgPiggyBank,
|
||||
SvgShare,
|
||||
SvgTime,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import {
|
||||
SvgArrowsSynchronize,
|
||||
SvgCalendar3,
|
||||
} from '@actual-app/components/icons/v2';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import type { DisplayTemplateType } from './constants';
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
export type DisplayTemplateMeta = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: IconComponent;
|
||||
};
|
||||
|
||||
export function getDisplayTemplateMeta(
|
||||
displayType: DisplayTemplateType,
|
||||
): DisplayTemplateMeta {
|
||||
switch (displayType) {
|
||||
case 'fixed':
|
||||
return {
|
||||
label: t('Fixed amount'),
|
||||
description: t('Add a set amount every month, week, day, or year.'),
|
||||
icon: SvgPiggyBank,
|
||||
};
|
||||
case 'schedule':
|
||||
return {
|
||||
label: t('Cover schedule'),
|
||||
description: t('Save up for a recurring scheduled transaction.'),
|
||||
icon: SvgCalendar3,
|
||||
};
|
||||
case 'by':
|
||||
return {
|
||||
label: t('Save by date'),
|
||||
description: t(
|
||||
'Spread a target amount across the months until a deadline.',
|
||||
),
|
||||
icon: SvgMoneyBag,
|
||||
};
|
||||
case 'percentage':
|
||||
return {
|
||||
label: t('% of income'),
|
||||
description: t("A share of this month's or last month's income."),
|
||||
icon: SvgChartPie,
|
||||
};
|
||||
case 'historical':
|
||||
return {
|
||||
label: t('From history'),
|
||||
description: t(
|
||||
'Use past months: average, a specific month, or a copy.',
|
||||
),
|
||||
icon: SvgTime,
|
||||
};
|
||||
case 'limit':
|
||||
return {
|
||||
label: t('Balance cap'),
|
||||
description: t('Never let the category balance exceed a cap.'),
|
||||
icon: SvgEquals,
|
||||
};
|
||||
case 'refill':
|
||||
return {
|
||||
label: t('Refill to cap'),
|
||||
description: t(
|
||||
'Top the category back up to the balance cap each month.',
|
||||
),
|
||||
icon: SvgArrowsSynchronize,
|
||||
};
|
||||
case 'remainder':
|
||||
return {
|
||||
label: t('Whatever is left'),
|
||||
description: t(
|
||||
'Split any remaining To Budget across these categories.',
|
||||
),
|
||||
icon: SvgShare,
|
||||
};
|
||||
default:
|
||||
displayType satisfies never;
|
||||
throw new Error(`Unknown display type: ${String(displayType)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
|
||||
import type { ByTemplate } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { updateTemplate } from '#components/budget/goals/actions';
|
||||
import type { Action } from '#components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import { AmountInput } from '#components/util/AmountInput';
|
||||
import { GenericInput } from '#components/util/GenericInput';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
type BySaveAutomationProps = {
|
||||
template: ByTemplate;
|
||||
dispatch: (action: Action) => void;
|
||||
};
|
||||
|
||||
export const BySaveAutomation = ({
|
||||
template,
|
||||
dispatch,
|
||||
}: BySaveAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const amount = amountToInteger(
|
||||
template.amount,
|
||||
format.currency.decimalPlaces,
|
||||
);
|
||||
|
||||
const committedRepeat = template.repeat ?? 1;
|
||||
const [rawRepeat, setRawRepeat] = useState(String(committedRepeat));
|
||||
useEffect(() => {
|
||||
setRawRepeat(String(committedRepeat));
|
||||
}, [committedRepeat]);
|
||||
const commitRepeat = () => {
|
||||
const parsed = Math.max(1, Math.trunc(Number(rawRepeat)) || 1);
|
||||
setRawRepeat(String(parsed));
|
||||
if (parsed !== committedRepeat) {
|
||||
dispatch(updateTemplate({ type: 'by', repeat: parsed }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Total amount')} htmlFor="by-amount-field" />
|
||||
<AmountInput
|
||||
id="by-amount-field"
|
||||
value={amount}
|
||||
zeroSign="+"
|
||||
onUpdate={(value: number) =>
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'by',
|
||||
amount: integerToAmount(value, format.currency.decimalPlaces),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Target date')} htmlFor="by-month-field" />
|
||||
<GenericInput
|
||||
type="date"
|
||||
field="date"
|
||||
value={template.month ? `${template.month}-01` : ''}
|
||||
onChange={(value: string) =>
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'by',
|
||||
month: value ? value.slice(0, 7) : '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</SpaceBetween>
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel
|
||||
title={t('Repeat every')}
|
||||
htmlFor="by-repeat-amount-field"
|
||||
/>
|
||||
<Input
|
||||
id="by-repeat-amount-field"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={rawRepeat}
|
||||
onChangeValue={setRawRepeat}
|
||||
onBlur={commitRepeat}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Period')} htmlFor="by-period-field" />
|
||||
<Select
|
||||
id="by-period-field"
|
||||
value={template.annual ? 'year' : 'month'}
|
||||
onChange={value =>
|
||||
dispatch(updateTemplate({ type: 'by', annual: value === 'year' }))
|
||||
}
|
||||
options={[
|
||||
['month', t('Months')],
|
||||
['year', t('Years')],
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</SpaceBetween>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { amountToInteger } from '@actual-app/core/shared/util';
|
||||
import type { ByTemplate } from '@actual-app/core/types/models/templates';
|
||||
import type { TransObjectLiteral } from '@actual-app/core/types/util';
|
||||
|
||||
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
|
||||
import { FinancialText } from '#components/FinancialText';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { useLocale } from '#hooks/useLocale';
|
||||
|
||||
type BySaveAutomationReadOnlyProps = {
|
||||
template: ByTemplate;
|
||||
};
|
||||
|
||||
export const BySaveAutomationReadOnly = ({
|
||||
template,
|
||||
}: BySaveAutomationReadOnlyProps) => {
|
||||
const format = useFormat();
|
||||
const locale = useLocale();
|
||||
const amount = format(
|
||||
amountToInteger(template.amount, format.currency.decimalPlaces),
|
||||
'financial',
|
||||
);
|
||||
const month = formatMonthLabel(template.month, locale);
|
||||
const repeat = template.repeat ?? 1;
|
||||
|
||||
if (template.annual) {
|
||||
return (
|
||||
<Trans count={repeat}>
|
||||
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
by {{ month }}, repeating every {{ count: repeat }} years
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (template.repeat && template.repeat > 0) {
|
||||
return (
|
||||
<Trans count={repeat}>
|
||||
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
by {{ month }}, repeating every {{ count: repeat }} months
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
Save <FinancialText>{{ amount } as TransObjectLiteral}</FinancialText> by{' '}
|
||||
{{ month }}
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { amountToInteger, integerToAmount } from '@actual-app/core/shared/util';
|
||||
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { updateTemplate } from '#components/budget/goals/actions';
|
||||
import type { Action } from '#components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import { AmountInput } from '#components/util/AmountInput';
|
||||
import { GenericInput } from '#components/util/GenericInput';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
type FixedAutomationProps = {
|
||||
template: PeriodicTemplate;
|
||||
dispatch: (action: Action) => void;
|
||||
};
|
||||
|
||||
type PeriodUnit = 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
export const FixedAutomation = ({
|
||||
template,
|
||||
dispatch,
|
||||
}: FixedAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const periodUnitOptions: Array<[PeriodUnit, string]> = [
|
||||
['day', t('days')],
|
||||
['week', t('weeks')],
|
||||
['month', t('months')],
|
||||
['year', t('years')],
|
||||
];
|
||||
const format = useFormat();
|
||||
|
||||
const amount = amountToInteger(
|
||||
template.amount,
|
||||
format.currency.decimalPlaces,
|
||||
);
|
||||
const periodUnit = template.period?.period ?? 'month';
|
||||
const periodAmount = template.period?.amount ?? 1;
|
||||
const [rawPeriodAmount, setRawPeriodAmount] = useState(String(periodAmount));
|
||||
// Resync when a different automation row is selected (the component
|
||||
// instance is reused across rows).
|
||||
useEffect(() => {
|
||||
setRawPeriodAmount(String(periodAmount));
|
||||
}, [periodAmount]);
|
||||
const commitPeriodAmount = () => {
|
||||
const parsed = Math.max(1, Math.trunc(Number(rawPeriodAmount)) || 1);
|
||||
setRawPeriodAmount(String(parsed));
|
||||
if (parsed !== periodAmount) {
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'periodic',
|
||||
period: { period: periodUnit, amount: parsed },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Amount')} htmlFor="amount-field" />
|
||||
<AmountInput
|
||||
id="amount-field"
|
||||
value={amount}
|
||||
zeroSign="+"
|
||||
onUpdate={(value: number) =>
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'periodic',
|
||||
amount: integerToAmount(value, format.currency.decimalPlaces),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Every')} htmlFor="period-amount-field" />
|
||||
<Input
|
||||
id="period-amount-field"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={rawPeriodAmount}
|
||||
onChangeValue={setRawPeriodAmount}
|
||||
onBlur={commitPeriodAmount}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Period')} htmlFor="period-unit-field" />
|
||||
<Select
|
||||
id="period-unit-field"
|
||||
value={periodUnit}
|
||||
onChange={value =>
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'periodic',
|
||||
period: {
|
||||
period: value,
|
||||
amount: periodAmount,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
options={periodUnitOptions}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Starting')} htmlFor="starting-field" />
|
||||
<GenericInput
|
||||
type="date"
|
||||
field="date"
|
||||
value={template.starting ?? ''}
|
||||
onChange={(value: string) =>
|
||||
dispatch(updateTemplate({ type: 'periodic', starting: value }))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</SpaceBetween>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { amountToInteger } from '@actual-app/core/shared/util';
|
||||
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
|
||||
import type { TransObjectLiteral } from '@actual-app/core/types/util';
|
||||
|
||||
import { FinancialText } from '#components/FinancialText';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
type FixedAutomationReadOnlyProps = {
|
||||
template: PeriodicTemplate;
|
||||
};
|
||||
|
||||
export function FixedAutomationReadOnly({
|
||||
template,
|
||||
}: FixedAutomationReadOnlyProps) {
|
||||
const format = useFormat();
|
||||
const amount = format(
|
||||
amountToInteger(template.amount, format.currency.decimalPlaces),
|
||||
'financial',
|
||||
);
|
||||
const periodAmount = template.period?.amount ?? 1;
|
||||
const periodUnit = template.period?.period ?? 'month';
|
||||
|
||||
switch (periodUnit) {
|
||||
case 'day':
|
||||
return (
|
||||
<Trans count={periodAmount}>
|
||||
Budget{' '}
|
||||
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
every {{ count: periodAmount }} days
|
||||
</Trans>
|
||||
);
|
||||
case 'week':
|
||||
return (
|
||||
<Trans count={periodAmount}>
|
||||
Budget{' '}
|
||||
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
every {{ count: periodAmount }} weeks
|
||||
</Trans>
|
||||
);
|
||||
case 'month':
|
||||
return (
|
||||
<Trans count={periodAmount}>
|
||||
Budget{' '}
|
||||
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
every {{ count: periodAmount }} months
|
||||
</Trans>
|
||||
);
|
||||
case 'year':
|
||||
return (
|
||||
<Trans count={periodAmount}>
|
||||
Budget{' '}
|
||||
<FinancialText>{{ amount } as TransObjectLiteral}</FinancialText>{' '}
|
||||
every {{ count: periodAmount }} years
|
||||
</Trans>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ export const HistoricalAutomationReadOnly = ({
|
||||
template,
|
||||
}: HistoricalAutomationReadOnlyProps) => {
|
||||
return template.type === 'copy' ? (
|
||||
<Trans>
|
||||
Budget the same amount as {{ amount: template.lookBack }} months ago
|
||||
<Trans count={template.lookBack}>
|
||||
Budget the same amount as {{ count: template.lookBack }} months ago
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Budget the average of the last {{ amount: template.numMonths }} months
|
||||
<Trans count={template.numMonths}>
|
||||
Budget the average of the last {{ count: template.numMonths }} months
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Select } from '@actual-app/components/select';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import {
|
||||
currentDate,
|
||||
dayFromDate,
|
||||
@@ -18,6 +17,7 @@ import { setDay } from 'date-fns/setDay';
|
||||
import { updateTemplate } from '#components/budget/goals/actions';
|
||||
import type { Action } from '#components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import { LabeledCheckbox } from '#components/forms/LabeledCheckbox';
|
||||
import { AmountInput } from '#components/util/AmountInput';
|
||||
import { useDaysOfWeek } from '#hooks/useDaysOfWeek';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
@@ -115,26 +115,21 @@ export const LimitAutomation = ({
|
||||
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
{period === 'weekly' && amountField}
|
||||
<FormField key="excess-funds-field" style={{ flex: 1 }}>
|
||||
<FormLabel
|
||||
title={t('Excess funds mode')}
|
||||
htmlFor="excess-funds-field"
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="excess-funds-field"
|
||||
value={hold}
|
||||
onChange={value =>
|
||||
dispatch(updateTemplate({ type: 'limit', hold: value }))
|
||||
<FormField key="hold-overflow-field" style={{ flex: 1 }}>
|
||||
<LabeledCheckbox
|
||||
id="hold-overflow-field"
|
||||
checked={!!hold}
|
||||
onChange={e =>
|
||||
dispatch(
|
||||
updateTemplate({ type: 'limit', hold: e.target.checked }),
|
||||
)
|
||||
}
|
||||
options={[
|
||||
[false, t('Remove all funds over the limit')],
|
||||
[true, t('Retain any funds over the limit')],
|
||||
]}
|
||||
className={selectButtonClassName}
|
||||
/>
|
||||
>
|
||||
<span style={{ marginLeft: 6, fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
<Trans>Retain existing funds over the cap</Trans>
|
||||
</span>
|
||||
</LabeledCheckbox>
|
||||
</FormField>
|
||||
{period !== 'weekly' && <View style={{ flex: 1 }} />}
|
||||
</SpaceBetween>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ export const PercentageAutomation = ({
|
||||
? categories.map(group => ({
|
||||
...group,
|
||||
categories: group.categories?.filter(
|
||||
category => category.id !== 'to-budget',
|
||||
category => category.id !== 'available funds',
|
||||
),
|
||||
}))
|
||||
: categories
|
||||
@@ -87,7 +87,7 @@ export const PercentageAutomation = ({
|
||||
updateTemplate({
|
||||
type: 'percentage',
|
||||
previous,
|
||||
...(previous && template.category === 'to-budget'
|
||||
...(previous && template.category === 'available funds'
|
||||
? { category: '' }
|
||||
: {}),
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ export const PercentageAutomationReadOnly = ({
|
||||
}: PercentageAutomationReadOnlyProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (template.category === 'total') {
|
||||
if (template.category === 'all income') {
|
||||
return template.previous ? (
|
||||
<Trans>
|
||||
Budget {{ percent: template.percent }}% of total income last month
|
||||
@@ -25,7 +25,7 @@ export const PercentageAutomationReadOnly = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (template.category === 'to-budget') {
|
||||
if (template.category === 'available funds') {
|
||||
return template.previous ? (
|
||||
<Trans>
|
||||
Budget {{ percent: template.percent }}% of available funds to budget
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { updateTemplate } from '#components/budget/goals/actions';
|
||||
import type { Action } from '#components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
|
||||
type RemainderAutomationProps = {
|
||||
template: RemainderTemplate;
|
||||
dispatch: (action: Action) => void;
|
||||
};
|
||||
|
||||
export const RemainderAutomation = ({
|
||||
template,
|
||||
dispatch,
|
||||
}: RemainderAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const committedWeight = template.weight ?? 1;
|
||||
// Track the raw input so the user can clear and retype without the field
|
||||
// snapping back. Commit (and clamp) on blur.
|
||||
const [rawWeight, setRawWeight] = useState(String(committedWeight));
|
||||
useEffect(() => {
|
||||
setRawWeight(String(committedWeight));
|
||||
}, [committedWeight]);
|
||||
|
||||
const commitWeight = () => {
|
||||
const parsed = Math.max(1, Math.trunc(Number(rawWeight)) || 1);
|
||||
setRawWeight(String(parsed));
|
||||
if (parsed !== committedWeight) {
|
||||
dispatch(updateTemplate({ type: 'remainder', weight: parsed }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Weight')} htmlFor="remainder-weight-field" />
|
||||
<Input
|
||||
id="remainder-weight-field"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={rawWeight}
|
||||
onChangeValue={setRawWeight}
|
||||
onBlur={commitWeight}
|
||||
/>
|
||||
</FormField>
|
||||
<Text style={{ flex: 2, color: theme.pageTextSubdued, fontSize: 12 }}>
|
||||
<Trans>
|
||||
Categories with higher weights get a bigger share of the leftover To
|
||||
Budget.
|
||||
</Trans>
|
||||
</Text>
|
||||
</SpaceBetween>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import type { RemainderTemplate } from '@actual-app/core/types/models/templates';
|
||||
|
||||
type RemainderAutomationReadOnlyProps = {
|
||||
template: RemainderTemplate;
|
||||
};
|
||||
|
||||
export const RemainderAutomationReadOnly = ({
|
||||
template,
|
||||
}: RemainderAutomationReadOnlyProps) => {
|
||||
return (
|
||||
<Trans>
|
||||
Share remaining funds to budget (weight {{ weight: template.weight ?? 1 }}
|
||||
)
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
@@ -23,8 +23,16 @@ export const ScheduleAutomation = ({
|
||||
dispatch,
|
||||
}: ScheduleAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Match the filter applied to the Select options below — completed and
|
||||
// tombstoned schedules aren't selectable, so a category whose only
|
||||
// schedules are completed should fall through to the "no schedules" state
|
||||
// instead of showing an empty picker.
|
||||
const selectableSchedules = schedules.filter(
|
||||
(s): s is typeof s & { name: string } =>
|
||||
!!s.name && !s.completed && !s.tombstone,
|
||||
);
|
||||
|
||||
return schedules.length ? (
|
||||
return selectableSchedules.length ? (
|
||||
<SpaceBetween gap={50} style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Schedule')} htmlFor="schedule-field" />
|
||||
@@ -41,9 +49,7 @@ export const ScheduleAutomation = ({
|
||||
}),
|
||||
)
|
||||
}
|
||||
options={schedules.flatMap(schedule =>
|
||||
schedule.name ? [[schedule.name, schedule.name]] : [],
|
||||
)}
|
||||
options={selectableSchedules.map(s => [s.name, s.name] as const)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { updateTemplate } from '#components/budget/goals/actions';
|
||||
import type { Action } from '#components/budget/goals/actions';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import { AmountInput } from '#components/util/AmountInput';
|
||||
|
||||
type WeekAutomationProps = {
|
||||
template: PeriodicTemplate;
|
||||
dispatch: (action: Action) => void;
|
||||
};
|
||||
|
||||
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Amount')} htmlFor="amount-field" />
|
||||
<AmountInput
|
||||
id="amount-field"
|
||||
key="amount-input"
|
||||
value={template.amount ?? 0}
|
||||
zeroSign="+"
|
||||
onUpdate={(value: number) =>
|
||||
dispatch(
|
||||
updateTemplate({
|
||||
type: 'periodic',
|
||||
amount: value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import type { PeriodicTemplate } from '@actual-app/core/types/models/templates';
|
||||
import type { TransObjectLiteral } from '@actual-app/core/types/util';
|
||||
|
||||
import { FinancialText } from '#components/FinancialText';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
type WeekAutomationReadOnlyProps = {
|
||||
template: PeriodicTemplate;
|
||||
};
|
||||
|
||||
export const WeekAutomationReadOnly = ({
|
||||
template,
|
||||
}: WeekAutomationReadOnlyProps) => {
|
||||
const format = useFormat();
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
Budget{' '}
|
||||
<FinancialText>
|
||||
{
|
||||
{
|
||||
amount: format(template.amount, 'financial'),
|
||||
} as TransObjectLiteral
|
||||
}
|
||||
</FinancialText>{' '}
|
||||
each week
|
||||
</Trans>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
|
||||
// Format a YYYY-MM string as "MMM yyyy" using the active locale (matching
|
||||
// the convention used elsewhere in the codebase via monthUtils.format).
|
||||
// Falls back to the raw input if it doesn't look like YYYY-MM, and to "—"
|
||||
// for empty/missing values so callers don't need their own guards.
|
||||
export function formatMonthLabel(
|
||||
month: string | undefined | null,
|
||||
locale?: Parameters<typeof monthUtils.format>[2],
|
||||
): string {
|
||||
if (!month) return '—';
|
||||
if (!monthUtils.isValidYearMonth(month)) return month;
|
||||
return monthUtils.format(`${month}-01`, 'MMM yyyy', locale);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { firstDayOfMonth } from '@actual-app/core/shared/months';
|
||||
import {
|
||||
addMonths,
|
||||
dayFromDate,
|
||||
firstDayOfMonth,
|
||||
monthFromDate,
|
||||
} from '@actual-app/core/shared/months';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import type { Action } from './actions';
|
||||
@@ -25,7 +30,7 @@ export const getInitialState = (template: Template | null): ReducerState => {
|
||||
priority: template.priority,
|
||||
directive: template.directive,
|
||||
},
|
||||
displayType: 'week',
|
||||
displayType: 'fixed',
|
||||
};
|
||||
case 'percentage':
|
||||
return {
|
||||
@@ -40,13 +45,20 @@ export const getInitialState = (template: Template | null): ReducerState => {
|
||||
case 'periodic':
|
||||
return {
|
||||
template,
|
||||
displayType: 'week',
|
||||
displayType: 'fixed',
|
||||
};
|
||||
case 'spend':
|
||||
case 'by':
|
||||
throw new Error('Goal is not yet supported');
|
||||
case 'by':
|
||||
return {
|
||||
template,
|
||||
displayType: 'by',
|
||||
};
|
||||
case 'remainder':
|
||||
throw new Error('Remainder is not yet supported');
|
||||
return {
|
||||
template,
|
||||
displayType: 'remainder',
|
||||
};
|
||||
case 'limit':
|
||||
return {
|
||||
template,
|
||||
@@ -117,7 +129,7 @@ const changeType = (
|
||||
type: 'percentage',
|
||||
percent: 15,
|
||||
previous: false,
|
||||
category: 'total',
|
||||
category: 'all income',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
@@ -134,7 +146,7 @@ const changeType = (
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
case 'week':
|
||||
case 'fixed':
|
||||
if (prevState.template.type === 'periodic') {
|
||||
return prevState;
|
||||
}
|
||||
@@ -143,12 +155,12 @@ const changeType = (
|
||||
template: {
|
||||
directive: 'template',
|
||||
type: 'periodic',
|
||||
amount: 5,
|
||||
amount: 100,
|
||||
period: {
|
||||
period: 'week',
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
starting: '',
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
@@ -168,6 +180,35 @@ const changeType = (
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
case 'by':
|
||||
if (prevState.template.type === 'by') {
|
||||
return prevState;
|
||||
}
|
||||
return {
|
||||
displayType: visualType,
|
||||
template: {
|
||||
directive: 'template',
|
||||
type: 'by',
|
||||
amount: 1200,
|
||||
month: addMonths(monthFromDate(new Date()), 12),
|
||||
annual: true,
|
||||
repeat: 1,
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
};
|
||||
case 'remainder':
|
||||
if (prevState.template.type === 'remainder') {
|
||||
return prevState;
|
||||
}
|
||||
return {
|
||||
displayType: visualType,
|
||||
template: {
|
||||
directive: 'template',
|
||||
type: 'remainder',
|
||||
weight: 1,
|
||||
priority: null,
|
||||
},
|
||||
};
|
||||
default:
|
||||
// Make sure we're not missing any cases
|
||||
throw new Error(
|
||||
|
||||
@@ -7,21 +7,24 @@ export function useBudgetAutomationCategories() {
|
||||
const { t } = useTranslation();
|
||||
const { data: { grouped } = { grouped: [] } } = useCategories();
|
||||
const categories = useMemo(() => {
|
||||
const incomeGroup = grouped.filter(group => group.name === 'Income')[0];
|
||||
const incomeGroups = grouped.filter(group => group.is_income);
|
||||
return [
|
||||
{
|
||||
id: '',
|
||||
name: t('Special categories'),
|
||||
categories: [
|
||||
{ id: 'total', group: '', name: t('Total of all income') },
|
||||
{ id: 'all income', group: '', name: t('Total of all income') },
|
||||
{
|
||||
id: 'to-budget',
|
||||
id: 'available funds',
|
||||
group: '',
|
||||
name: t('Available funds to budget'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ...incomeGroup, name: t('Income categories') },
|
||||
...incomeGroups.map(group => ({
|
||||
...group,
|
||||
name: t('Income categories'),
|
||||
})),
|
||||
];
|
||||
}, [grouped, t]);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validatePercentageAllocation } from './validateAutomation';
|
||||
|
||||
function percent(
|
||||
category: string,
|
||||
percent: number,
|
||||
previous = false,
|
||||
): Template {
|
||||
return {
|
||||
type: 'percentage',
|
||||
percent,
|
||||
previous,
|
||||
category,
|
||||
directive: 'template',
|
||||
priority: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('validatePercentageAllocation', () => {
|
||||
it('returns null when no percentage templates are present', () => {
|
||||
expect(validatePercentageAllocation([])).toBeNull();
|
||||
});
|
||||
|
||||
it('flags a single source over 100%', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 60),
|
||||
percent('Salary', 50),
|
||||
]),
|
||||
).toEqual({ kind: 'percent-over-100', total: 110 });
|
||||
});
|
||||
|
||||
it('does not sum across distinct income sources', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Income-HSA', 100, true),
|
||||
percent('Interest-HSA', 100),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats this-month and last-month income as different sources', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 100, false),
|
||||
percent('Salary', 100, true),
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores templates with a missing source', () => {
|
||||
const orphan = {
|
||||
...percent('Salary', 100),
|
||||
category: null as unknown as string,
|
||||
};
|
||||
expect(validatePercentageAllocation([orphan])).toBeNull();
|
||||
});
|
||||
|
||||
it('matches sources case-insensitively', () => {
|
||||
expect(
|
||||
validatePercentageAllocation([
|
||||
percent('Salary', 60),
|
||||
percent('salary', 50),
|
||||
]),
|
||||
).toEqual({ kind: 'percent-over-100', total: 110 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
import type { ScheduleEntity } from '@actual-app/core/types/models';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import type { DisplayTemplateType } from './constants';
|
||||
|
||||
export type AutomationErrorKind =
|
||||
| { kind: 'schedule-not-found'; name: string }
|
||||
| { kind: 'refill-no-cap' }
|
||||
| { kind: 'percentage-out-of-range'; percent: number }
|
||||
| { kind: 'percentage-no-source' }
|
||||
| { kind: 'percentage-source-not-found'; source: string }
|
||||
| { kind: 'by-no-month' }
|
||||
| { kind: 'by-target-past'; month: string };
|
||||
|
||||
export type GlobalConflictKind =
|
||||
| { kind: 'over-income'; total: number; income: number }
|
||||
| { kind: 'percent-over-100'; total: number };
|
||||
|
||||
export function validateAutomation(
|
||||
template: Template,
|
||||
displayType: DisplayTemplateType,
|
||||
allTemplates: readonly Template[],
|
||||
schedules: readonly ScheduleEntity[],
|
||||
today: Date,
|
||||
// Set of recognised percentage sources (income category ids, lower-cased
|
||||
// category names, and special source aliases like 'all income'). When
|
||||
// omitted the source-not-found check is skipped (the engine still validates
|
||||
// server-side at apply time).
|
||||
validPercentageSources?: ReadonlySet<string>,
|
||||
): AutomationErrorKind | null {
|
||||
switch (displayType) {
|
||||
case 'schedule':
|
||||
if (template.type !== 'schedule') return null;
|
||||
if (!template.name) return { kind: 'schedule-not-found', name: '' };
|
||||
if (
|
||||
!schedules.some(
|
||||
s => s.name === template.name && !s.completed && !s.tombstone,
|
||||
)
|
||||
) {
|
||||
return { kind: 'schedule-not-found', name: template.name };
|
||||
}
|
||||
return null;
|
||||
case 'refill':
|
||||
if (!allTemplates.some(t => t.type === 'limit')) {
|
||||
return { kind: 'refill-no-cap' };
|
||||
}
|
||||
return null;
|
||||
case 'percentage':
|
||||
if (template.type !== 'percentage') return null;
|
||||
if (!template.category) return { kind: 'percentage-no-source' };
|
||||
if (template.percent <= 0 || template.percent > 100) {
|
||||
return {
|
||||
kind: 'percentage-out-of-range',
|
||||
percent: template.percent,
|
||||
};
|
||||
}
|
||||
if (
|
||||
validPercentageSources &&
|
||||
!validPercentageSources.has(template.category) &&
|
||||
!validPercentageSources.has(template.category.toLowerCase())
|
||||
) {
|
||||
return {
|
||||
kind: 'percentage-source-not-found',
|
||||
source: template.category,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
case 'by': {
|
||||
if (template.type !== 'by') return null;
|
||||
if (!template.month || !monthUtils.isValidYearMonth(template.month)) {
|
||||
return { kind: 'by-no-month' };
|
||||
}
|
||||
const targetMonth = template.month;
|
||||
const startOfTodayMonth = monthUtils.monthFromDate(today);
|
||||
// Pass bare YYYY-MM strings, matching the server-side check in
|
||||
// CategoryTemplateContext.checkByAndScheduleAndSpend and avoiding the
|
||||
// local-vs-UTC parsing footgun called out in shared/months.ts:_parse.
|
||||
const monthsRemaining = monthUtils.differenceInCalendarMonths(
|
||||
targetMonth,
|
||||
startOfTodayMonth,
|
||||
);
|
||||
// Recurring goals (annual/repeat) anchored on a past month are
|
||||
// legitimate — the engine rolls them forward by the period. Only flag
|
||||
// the past-target case for one-shot goals. Mirrors the server check in
|
||||
// CategoryTemplateContext.checkByAndScheduleAndSpend.
|
||||
if (monthsRemaining < 0 && !template.annual && !template.repeat) {
|
||||
return { kind: 'by-target-past', month: targetMonth };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePercentageAllocation(
|
||||
templates: readonly Template[],
|
||||
): GlobalConflictKind | null {
|
||||
const percentBySource = new Map<string, number>();
|
||||
for (const t of templates) {
|
||||
if (t.type !== 'percentage' || !t.category) continue;
|
||||
const key = `${t.previous}|${t.category.toLocaleLowerCase()}`;
|
||||
percentBySource.set(key, (percentBySource.get(key) ?? 0) + t.percent);
|
||||
}
|
||||
const maxPercent = Math.max(0, ...percentBySource.values());
|
||||
return maxPercent > 100
|
||||
? { kind: 'percent-over-100', total: maxPercent }
|
||||
: null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Toggle } from '@actual-app/components/toggle';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
type FieldLabelProps = {
|
||||
@@ -46,15 +47,71 @@ const valueStyle = {
|
||||
height: styles.mobileMinHeight,
|
||||
};
|
||||
|
||||
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
|
||||
const hideNativeDateIconClassName = css({
|
||||
'&::-webkit-calendar-picker-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
type InputFieldProps = ComponentPropsWithRef<typeof Input> & {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function InputField({
|
||||
disabled,
|
||||
style,
|
||||
onUpdate,
|
||||
icon,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: InputFieldProps) {
|
||||
if (icon) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...valueStyle,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
gap: 8,
|
||||
backgroundColor: disabled
|
||||
? theme.formInputTextReadOnlySelection
|
||||
: theme.tableBackground,
|
||||
}}
|
||||
>
|
||||
<View style={{ color: theme.pageTextSubdued, flexShrink: 0 }}>
|
||||
{icon}
|
||||
</View>
|
||||
<Input
|
||||
ref={ref}
|
||||
autoCorrect="false"
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
color: disabled ? theme.tableTextInactive : theme.tableText,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
className={renderProps =>
|
||||
cx(
|
||||
hideNativeDateIconClassName,
|
||||
typeof className === 'function'
|
||||
? className(renderProps)
|
||||
: className,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
@@ -62,6 +119,7 @@ export function InputField({
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
onUpdate={onUpdate}
|
||||
className={className}
|
||||
style={{
|
||||
...valueStyle,
|
||||
...style,
|
||||
@@ -78,6 +136,8 @@ export function InputField({
|
||||
InputField.displayName = 'InputField';
|
||||
|
||||
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
|
||||
icon?: ReactNode;
|
||||
placeholder?: string;
|
||||
rightContent?: ReactNode;
|
||||
alwaysShowRightContent?: boolean;
|
||||
textStyle?: CSSProperties;
|
||||
@@ -105,12 +165,15 @@ export function TapField({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
placeholder,
|
||||
rightContent,
|
||||
alwaysShowRightContent,
|
||||
textStyle,
|
||||
ref,
|
||||
...props
|
||||
}: TapFieldProps) {
|
||||
const showPlaceholder = !value && !!placeholder;
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
@@ -126,16 +189,32 @@ export function TapField({
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
userSelect: 'none',
|
||||
textAlign: 'left',
|
||||
...textStyle,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
<>
|
||||
{icon && (
|
||||
<View
|
||||
style={{
|
||||
color: theme.pageTextSubdued,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
userSelect: 'none',
|
||||
textAlign: 'left',
|
||||
color: showPlaceholder
|
||||
? theme.formInputTextPlaceholder
|
||||
: undefined,
|
||||
...textStyle,
|
||||
}}
|
||||
>
|
||||
{showPlaceholder ? placeholder : value}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
|
||||
</Button>
|
||||
|
||||
@@ -5,14 +5,17 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import type {
|
||||
GroupedBankSyncAccounts,
|
||||
SyncProviders,
|
||||
} from '#components/banksync/bankSyncUtils';
|
||||
import { getGroupedBankSyncEntries } from '#components/banksync/bankSyncUtils';
|
||||
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
|
||||
|
||||
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
|
||||
|
||||
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
|
||||
|
||||
type BankSyncAccountsListProps = {
|
||||
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
|
||||
groupedAccounts: GroupedBankSyncAccounts;
|
||||
syncSourceReadable: Record<SyncProviders, string>;
|
||||
onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
|
||||
};
|
||||
@@ -22,7 +25,8 @@ export function BankSyncAccountsList({
|
||||
syncSourceReadable,
|
||||
onAction,
|
||||
}: BankSyncAccountsListProps) {
|
||||
const allAccounts = Object.values(groupedAccounts).flat();
|
||||
const groupedAccountEntries = getGroupedBankSyncEntries(groupedAccounts);
|
||||
const allAccounts = groupedAccountEntries.flatMap(([, accounts]) => accounts);
|
||||
|
||||
if (allAccounts.length === 0) {
|
||||
return (
|
||||
@@ -47,15 +51,13 @@ export function BankSyncAccountsList({
|
||||
);
|
||||
}
|
||||
|
||||
const shouldShowProviderHeaders = Object.keys(groupedAccounts).length > 1;
|
||||
const shouldShowProviderHeaders = groupedAccountEntries.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ flex: 1, overflow: 'auto', paddingBottom: MOBILE_NAV_HEIGHT }}
|
||||
>
|
||||
{(
|
||||
Object.entries(groupedAccounts) as [SyncProviders, AccountEntity[]][]
|
||||
).map(([provider, accounts]) => (
|
||||
{groupedAccountEntries.map(([provider, accounts]) => (
|
||||
<div key={provider}>
|
||||
{shouldShowProviderHeaders && (
|
||||
<div
|
||||
|
||||
@@ -5,11 +5,14 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import {
|
||||
getGroupedBankSyncEntries,
|
||||
getSyncSourceReadable,
|
||||
groupBankSyncAccounts,
|
||||
} from '#components/banksync/bankSyncUtils';
|
||||
import type { GroupedBankSyncAccounts } from '#components/banksync/bankSyncUtils';
|
||||
import { Search } from '#components/common/Search';
|
||||
import { MobilePageHeader, Page } from '#components/Page';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
@@ -19,79 +22,42 @@ import { useDispatch } from '#redux';
|
||||
|
||||
import { BankSyncAccountsList } from './BankSyncAccountsList';
|
||||
|
||||
type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
|
||||
const useSyncSourceReadable = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const syncSourceReadable: Record<SyncProviders, string> = {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
unlinked: t('Unlinked'),
|
||||
};
|
||||
|
||||
return { syncSourceReadable };
|
||||
};
|
||||
|
||||
export function MobileBankSyncPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { syncSourceReadable } = useSyncSourceReadable();
|
||||
const { data: accounts = [] } = useAccounts();
|
||||
const [filter, setFilter] = useState('');
|
||||
const syncSourceReadable = useMemo(() => getSyncSourceReadable(t), [t]);
|
||||
|
||||
const openAccounts = useMemo(
|
||||
() => accounts.filter(a => !a.closed),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const groupedAccounts = useMemo(() => {
|
||||
const unsorted = openAccounts.reduce(
|
||||
(acc, a) => {
|
||||
const syncSource = a.account_sync_source ?? 'unlinked';
|
||||
acc[syncSource] = acc[syncSource] || [];
|
||||
acc[syncSource].push(a);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
|
||||
const sortedKeys = Object.keys(unsorted).sort((keyA, keyB) => {
|
||||
if (keyA === 'unlinked') return 1;
|
||||
if (keyB === 'unlinked') return -1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
return sortedKeys.reduce(
|
||||
(sorted, key) => {
|
||||
sorted[key as SyncProviders] = unsorted[key as SyncProviders];
|
||||
return sorted;
|
||||
},
|
||||
{} as Record<SyncProviders, AccountEntity[]>,
|
||||
);
|
||||
}, [openAccounts]);
|
||||
const groupedAccounts = useMemo(
|
||||
() => groupBankSyncAccounts(openAccounts),
|
||||
[openAccounts],
|
||||
);
|
||||
|
||||
const filteredGroupedAccounts = useMemo(() => {
|
||||
if (!filter) return groupedAccounts;
|
||||
|
||||
const filterLower = filter.toLowerCase();
|
||||
const filtered: Record<SyncProviders, AccountEntity[]> = {} as Record<
|
||||
SyncProviders,
|
||||
AccountEntity[]
|
||||
>;
|
||||
const filtered: GroupedBankSyncAccounts = {};
|
||||
|
||||
Object.entries(groupedAccounts).forEach(([provider, accounts]) => {
|
||||
const filteredAccounts = accounts.filter(
|
||||
account =>
|
||||
account.name.toLowerCase().includes(filterLower) ||
|
||||
account.bankName?.toLowerCase().includes(filterLower),
|
||||
);
|
||||
if (filteredAccounts.length > 0) {
|
||||
filtered[provider as SyncProviders] = filteredAccounts;
|
||||
}
|
||||
});
|
||||
getGroupedBankSyncEntries(groupedAccounts).forEach(
|
||||
([provider, accounts]) => {
|
||||
const filteredAccounts = accounts.filter(
|
||||
account =>
|
||||
account.name.toLowerCase().includes(filterLower) ||
|
||||
account.bankName?.toLowerCase().includes(filterLower),
|
||||
);
|
||||
if (filteredAccounts.length > 0) {
|
||||
filtered[provider] = filteredAccounts;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return filtered;
|
||||
}, [groupedAccounts, filter]);
|
||||
|
||||
@@ -160,7 +160,7 @@ const AmountInput = memo(function AmountInput({
|
||||
}}
|
||||
data-testid="amount-input-text"
|
||||
>
|
||||
{editing ? text : amountToCurrency(value)}
|
||||
{editing ? text || amountToCurrency(0) : amountToCurrency(value)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -14,11 +14,19 @@ import { Button } from '@actual-app/components/button';
|
||||
import { SvgSplit } from '@actual-app/components/icons/v0';
|
||||
import {
|
||||
SvgAdd,
|
||||
SvgCalendar,
|
||||
SvgCheveronDown,
|
||||
SvgLocation,
|
||||
SvgPiggyBank,
|
||||
SvgTag,
|
||||
SvgTrash,
|
||||
SvgUser,
|
||||
SvgWallet,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { SvgPencilWriteAlternate } from '@actual-app/components/icons/v2';
|
||||
import {
|
||||
SvgNotesPaper,
|
||||
SvgPencilWriteAlternate,
|
||||
} from '@actual-app/components/icons/v2';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -164,6 +172,14 @@ export function lookupName(items: CategoryEntity[], id?: CategoryEntity['id']) {
|
||||
return items.find(item => item.id === id)?.name;
|
||||
}
|
||||
|
||||
const dropdownChevron = (
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ color: theme.pageTextSubdued, marginRight: 8 }}
|
||||
/>
|
||||
);
|
||||
|
||||
export function Status({
|
||||
status,
|
||||
isSplit = false,
|
||||
@@ -429,6 +445,9 @@ const ChildTransactionEdit = forwardRef<
|
||||
<View style={{ flexBasis: '75%' }}>
|
||||
<FieldLabel title={t('Payee')} />
|
||||
<TapField
|
||||
icon={<SvgUser width={17} height={17} />}
|
||||
placeholder={t('Who did you pay?')}
|
||||
rightContent={dropdownChevron}
|
||||
isDisabled={
|
||||
!!editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'payee')
|
||||
@@ -477,6 +496,9 @@ const ChildTransactionEdit = forwardRef<
|
||||
<View>
|
||||
<FieldLabel title={t('Category')} />
|
||||
<TapField
|
||||
icon={<SvgTag width={17} height={17} />}
|
||||
placeholder={t('Select a category')}
|
||||
rightContent={dropdownChevron}
|
||||
textStyle={{
|
||||
...((isOffBudget || isBudgetTransfer(transaction)) && {
|
||||
fontStyle: 'italic',
|
||||
@@ -499,6 +521,8 @@ const ChildTransactionEdit = forwardRef<
|
||||
<View>
|
||||
<FieldLabel title={t('Notes')} />
|
||||
<InputField
|
||||
icon={<SvgNotesPaper width={17} height={17} />}
|
||||
placeholder={t('Add a note (optional)')}
|
||||
disabled={
|
||||
!!editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'notes')
|
||||
@@ -1150,6 +1174,8 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
<View>
|
||||
<FieldLabel title={t('Payee')} />
|
||||
<TapField
|
||||
icon={<SvgUser width={17} height={17} />}
|
||||
placeholder={t('Who did you pay?')}
|
||||
textStyle={{
|
||||
...(transaction.is_parent && {
|
||||
fontStyle: 'italic',
|
||||
@@ -1211,7 +1237,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Button>
|
||||
) : undefined
|
||||
) : (
|
||||
dropdownChevron
|
||||
)
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
@@ -1220,6 +1248,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
<View>
|
||||
<FieldLabel title={t('Category')} />
|
||||
<TapField
|
||||
icon={<SvgTag width={17} height={17} />}
|
||||
placeholder={t('Select a category')}
|
||||
rightContent={dropdownChevron}
|
||||
style={{
|
||||
...((isOffBudget || isBudgetTransfer(transaction)) && {
|
||||
fontStyle: 'italic',
|
||||
@@ -1300,6 +1331,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
<View>
|
||||
<FieldLabel title={t('Account')} />
|
||||
<TapField
|
||||
icon={<SvgWallet width={17} height={17} />}
|
||||
placeholder={t('Select an account')}
|
||||
rightContent={dropdownChevron}
|
||||
isDisabled={
|
||||
!!editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'account')
|
||||
@@ -1315,6 +1349,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
<FieldLabel title={t('Date')} />
|
||||
<InputField
|
||||
type="date"
|
||||
icon={<SvgCalendar width={17} height={17} />}
|
||||
disabled={
|
||||
!!editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'date')
|
||||
@@ -1359,6 +1394,8 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
<View>
|
||||
<FieldLabel title={t('Notes')} />
|
||||
<InputField
|
||||
icon={<SvgNotesPaper width={17} height={17} />}
|
||||
placeholder={t('Add a note (optional)')}
|
||||
disabled={
|
||||
!!editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'notes')
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
|
||||
import { q } from '@actual-app/core/shared/query';
|
||||
import type {
|
||||
CategoryGroupEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
|
||||
import { Warning } from '#components/alerts';
|
||||
import { BudgetAutomation } from '#components/budget/goals/BudgetAutomation';
|
||||
import type { DisplayTemplateType } from '#components/budget/goals/constants';
|
||||
import { DEFAULT_PRIORITY } from '#components/budget/goals/reducer';
|
||||
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
|
||||
import { useCategory } from '#hooks/useCategory';
|
||||
import { useNotes } from '#hooks/useNotes';
|
||||
import { useSchedules } from '#hooks/useSchedules';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
type AutomationEntry = {
|
||||
id: string;
|
||||
template: Template;
|
||||
displayType: DisplayTemplateType;
|
||||
};
|
||||
|
||||
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
|
||||
switch (template.type) {
|
||||
case 'percentage':
|
||||
return 'percentage';
|
||||
case 'schedule':
|
||||
return 'schedule';
|
||||
case 'periodic':
|
||||
case 'simple':
|
||||
return 'week';
|
||||
case 'limit':
|
||||
return 'limit';
|
||||
case 'refill':
|
||||
return 'refill';
|
||||
case 'average':
|
||||
case 'copy':
|
||||
return 'historical';
|
||||
default:
|
||||
return 'week';
|
||||
}
|
||||
}
|
||||
|
||||
function createAutomationEntry(
|
||||
template: Template,
|
||||
displayType: DisplayTemplateType,
|
||||
): AutomationEntry {
|
||||
return {
|
||||
id: uniqueId('automation-'),
|
||||
template,
|
||||
displayType,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateTemplatesToAutomations(
|
||||
templates: Template[],
|
||||
): AutomationEntry[] {
|
||||
const entries: AutomationEntry[] = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
// Expand simple templates into limit, refill, and/or periodic templates
|
||||
if (template.type === 'simple') {
|
||||
let hasExpandedTemplate = false;
|
||||
|
||||
if (template.limit) {
|
||||
hasExpandedTemplate = true;
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'limit',
|
||||
amount: template.limit.amount,
|
||||
hold: template.limit.hold,
|
||||
period: template.limit.period,
|
||||
start: template.limit.start,
|
||||
directive: 'template',
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
),
|
||||
);
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'refill',
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'refill',
|
||||
),
|
||||
);
|
||||
}
|
||||
// If it has a monthly amount, create a periodic template
|
||||
if (template.monthly != null && template.monthly !== 0) {
|
||||
hasExpandedTemplate = true;
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: template.monthly,
|
||||
period: {
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'week',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasExpandedTemplate) {
|
||||
entries.push(
|
||||
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other template types, create a single entry
|
||||
entries.push(
|
||||
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
|
||||
);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function BudgetAutomationList({
|
||||
automations,
|
||||
setAutomations,
|
||||
schedules,
|
||||
categories,
|
||||
style,
|
||||
}: {
|
||||
automations: AutomationEntry[];
|
||||
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const onAdd = () => {
|
||||
setAutomations(prev => [
|
||||
...prev,
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: 500,
|
||||
period: {
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
'week',
|
||||
),
|
||||
]);
|
||||
};
|
||||
const onAddLimit = () => {
|
||||
setAutomations(prev => [
|
||||
...prev,
|
||||
createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'limit',
|
||||
amount: 500,
|
||||
period: 'monthly',
|
||||
hold: false,
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
),
|
||||
]);
|
||||
};
|
||||
const onDelete = (index: number) => () => {
|
||||
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
|
||||
};
|
||||
|
||||
const onSave = useCallback(
|
||||
(index: number) =>
|
||||
(template: Template, displayType: DisplayTemplateType) => {
|
||||
setAutomations(prev =>
|
||||
prev.map((oldAutomation, mapIndex) =>
|
||||
mapIndex === index
|
||||
? { ...oldAutomation, template, displayType }
|
||||
: oldAutomation,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setAutomations],
|
||||
);
|
||||
|
||||
const hasLimitAutomation = automations.some(
|
||||
automation => automation.displayType === 'limit',
|
||||
);
|
||||
|
||||
return (
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
gap={20}
|
||||
align="stretch"
|
||||
wrap={false}
|
||||
style={{
|
||||
overflowY: 'scroll',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{automations.map((automation, index) => (
|
||||
<BudgetAutomation
|
||||
key={automation.id}
|
||||
onSave={onSave(index)}
|
||||
onDelete={onDelete(index)}
|
||||
template={automation.template}
|
||||
categories={categories}
|
||||
schedules={schedules}
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={
|
||||
automation.displayType === 'refill' ? onAddLimit : undefined
|
||||
}
|
||||
readOnlyStyle={{
|
||||
color: theme.pillText,
|
||||
backgroundColor: theme.pillBackground,
|
||||
borderRadius: 4,
|
||||
padding: 16,
|
||||
paddingLeft: 30,
|
||||
paddingRight: 16,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Button onPress={onAdd}>
|
||||
<Trans>Add new automation</Trans>
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetAutomationMigrationWarning({
|
||||
categoryId,
|
||||
style,
|
||||
}: {
|
||||
categoryId: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const notes = useNotes(categoryId);
|
||||
|
||||
const templates = useMemo(() => {
|
||||
if (!notes) return null;
|
||||
const lines = notes.split('\n');
|
||||
return lines
|
||||
.flatMap(line => {
|
||||
if (line.trim().startsWith('#template')) return line;
|
||||
if (line.trim().startsWith('#goal')) return line;
|
||||
if (line.trim().startsWith('#cleanup')) return line;
|
||||
return [];
|
||||
})
|
||||
.join('\n');
|
||||
}, [notes]);
|
||||
|
||||
if (!templates) return null;
|
||||
|
||||
return (
|
||||
<Warning style={style}>
|
||||
<SpaceBetween direction="vertical" style={{ minHeight: 'unset' }}>
|
||||
<View>
|
||||
<Trans>
|
||||
This category uses notes-based automations (formerly "budget
|
||||
templates"). We have automatically imported your existing
|
||||
automations below. Please review them for accuracy and hit save to
|
||||
complete the migration.
|
||||
</Trans>
|
||||
</View>
|
||||
<View>
|
||||
<Trans>
|
||||
Original templates:
|
||||
<View
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'monospace',
|
||||
marginTop: 4,
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
{templates}
|
||||
</View>
|
||||
</Trans>
|
||||
</View>
|
||||
</SpaceBetween>
|
||||
</Warning>
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [automations, setAutomations] = useState<
|
||||
Record<string, AutomationEntry[]>
|
||||
>({});
|
||||
const onLoaded = useCallback((result: Record<string, Template[]>) => {
|
||||
const next: Record<string, AutomationEntry[]> = {};
|
||||
for (const [id, templates] of Object.entries(result)) {
|
||||
next[id] = migrateTemplatesToAutomations(templates);
|
||||
}
|
||||
setAutomations(next);
|
||||
}, []);
|
||||
|
||||
const { loading } = useBudgetAutomations({
|
||||
categoryId,
|
||||
onLoaded,
|
||||
});
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const { schedules } = useSchedules({
|
||||
query: schedulesQuery,
|
||||
});
|
||||
|
||||
const categories = useBudgetAutomationCategories();
|
||||
const { data: currentCategory } = useCategory(categoryId);
|
||||
|
||||
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
|
||||
|
||||
const onSave = async (close: () => void) => {
|
||||
if (!automations[categoryId]) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
const templates = automations[categoryId].map(({ template }) => template);
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [
|
||||
{
|
||||
id: categoryId,
|
||||
templates,
|
||||
},
|
||||
],
|
||||
source: 'ui',
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="category-automations-edit"
|
||||
containerProps={{
|
||||
style: { width: 850, height: 650, paddingBottom: 20 },
|
||||
}}
|
||||
>
|
||||
{({ state }) => (
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
wrap={false}
|
||||
align="stretch"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<ModalHeader
|
||||
title={t('Budget automations: {{category}}', {
|
||||
category: currentCategory?.name,
|
||||
})}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
{loading ? (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
</View>
|
||||
) : (
|
||||
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
|
||||
{needsMigration && (
|
||||
<BudgetAutomationMigrationWarning
|
||||
categoryId={categoryId}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<BudgetAutomationList
|
||||
automations={automations[categoryId] || []}
|
||||
setAutomations={(
|
||||
cb: (prev: AutomationEntry[]) => AutomationEntry[],
|
||||
) => {
|
||||
setAutomations(prev => ({
|
||||
...prev,
|
||||
[categoryId]: cb(prev[categoryId] || []),
|
||||
}));
|
||||
}}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
/>
|
||||
</SpaceBetween>
|
||||
)}
|
||||
<View style={{ flexGrow: 1 }} />
|
||||
<SpaceBetween
|
||||
style={{
|
||||
marginTop: 20,
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{!needsMigration && (
|
||||
<Link
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
const templates = automations[categoryId] || [];
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-automations-unmigrate',
|
||||
options: {
|
||||
categoryId,
|
||||
templates: templates.map(({ template }) => template),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans>Un-migrate</Trans>
|
||||
</Link>
|
||||
)}
|
||||
{/* <View style={{ flex: 1 }} /> */}
|
||||
<Button onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => onSave(() => state.close())}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
</SpaceBetween>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgDelete } from '@actual-app/components/icons/v0';
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import type {
|
||||
CategoryGroupEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { ActiveEditor } from '#components/budget/goals/ActiveEditor';
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import {
|
||||
AutomationErrorDetail,
|
||||
AutomationErrorTitle,
|
||||
} from '#components/budget/goals/automationMessages';
|
||||
import type { DisplayTemplateType } from '#components/budget/goals/constants';
|
||||
import {
|
||||
getInitialState,
|
||||
templateReducer,
|
||||
} from '#components/budget/goals/reducer';
|
||||
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
|
||||
|
||||
import { TypePicker } from './TypePicker';
|
||||
|
||||
const CONFIG_PANEL_CLASS = css({
|
||||
'& > *:first-child': {
|
||||
marginTop: 0,
|
||||
},
|
||||
'& span > label': {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextSubdued,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
// Match Select borders to text inputs (Button uses buttonNormalBorder which
|
||||
// is brighter than formInputBorder in dark/midnight themes).
|
||||
'& button[type="button"]:not([aria-pressed])': {
|
||||
borderColor: theme.formInputBorder,
|
||||
},
|
||||
});
|
||||
|
||||
const SINGLETON_TYPES: ReadonlySet<DisplayTemplateType> = new Set([
|
||||
'limit',
|
||||
'refill',
|
||||
'remainder',
|
||||
]);
|
||||
|
||||
type AutomationEditorPaneProps = {
|
||||
entries: AutomationEntry[];
|
||||
activeIdx: number;
|
||||
automationErrors: (AutomationErrorKind | null)[];
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
hasLimitAutomation: boolean;
|
||||
onAddLimitAutomation: () => void;
|
||||
setEntries: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
|
||||
onDelete: (index: number) => void;
|
||||
};
|
||||
|
||||
export function AutomationEditorPane({
|
||||
entries,
|
||||
activeIdx,
|
||||
automationErrors,
|
||||
schedules,
|
||||
categories,
|
||||
hasLimitAutomation,
|
||||
onAddLimitAutomation,
|
||||
setEntries,
|
||||
onDelete,
|
||||
}: AutomationEditorPaneProps) {
|
||||
const active = entries[activeIdx];
|
||||
const activeError = automationErrors[activeIdx];
|
||||
|
||||
const state = active ? getInitialState(active.template) : null;
|
||||
|
||||
const dispatch = (action: Parameters<typeof templateReducer>[1]) => {
|
||||
setEntries(prev =>
|
||||
prev.map((entry, i) => {
|
||||
if (i !== activeIdx) return entry;
|
||||
const current = getInitialState(entry.template);
|
||||
const next = templateReducer(current, action);
|
||||
return {
|
||||
id: entry.id,
|
||||
template: next.template,
|
||||
displayType: next.displayType,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const setPriority = (priority: number) => {
|
||||
setEntries(prev =>
|
||||
prev.map((entry, i) => {
|
||||
if (i !== activeIdx) return entry;
|
||||
const t = entry.template;
|
||||
switch (t.type) {
|
||||
case 'percentage':
|
||||
case 'periodic':
|
||||
case 'by':
|
||||
case 'spend':
|
||||
case 'simple':
|
||||
case 'schedule':
|
||||
case 'average':
|
||||
case 'copy':
|
||||
case 'refill':
|
||||
return { ...entry, template: { ...t, priority } };
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const disabledTypes = new Set<DisplayTemplateType>();
|
||||
entries.forEach((entry, i) => {
|
||||
if (i !== activeIdx && SINGLETON_TYPES.has(entry.displayType)) {
|
||||
disabledTypes.add(entry.displayType);
|
||||
}
|
||||
});
|
||||
|
||||
if (!active || !state) {
|
||||
return (
|
||||
<View style={{ padding: 20, color: theme.pageTextSubdued }}>
|
||||
<Trans>Select an automation on the left.</Trans>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
overflowY: 'auto',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{activeError && (
|
||||
<View
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.errorBackground,
|
||||
border: `1px solid ${theme.errorBorder}`,
|
||||
color: theme.errorText,
|
||||
fontSize: 13,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<SvgAlertTriangle
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ marginTop: 2, color: 'inherit', flexShrink: 0 }}
|
||||
/>
|
||||
<View style={{ minWidth: 0 }}>
|
||||
<Text style={{ fontWeight: 600, color: 'inherit' }}>
|
||||
<AutomationErrorTitle error={activeError} />
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<AutomationErrorDetail error={activeError} />
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<Trans>Automation type</Trans>
|
||||
</Text>
|
||||
<TypePicker
|
||||
active={state.displayType}
|
||||
disabledTypes={disabledTypes}
|
||||
onPick={type => dispatch({ type: 'set-type', payload: type })}
|
||||
/>
|
||||
|
||||
{state.displayType !== 'refill' && (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<Trans>Configuration</Trans>
|
||||
</Text>
|
||||
<View
|
||||
className={CONFIG_PANEL_CLASS}
|
||||
style={{
|
||||
padding: 16,
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
}}
|
||||
>
|
||||
<ActiveEditor
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={onAddLimitAutomation}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.displayType === 'refill' && (
|
||||
<ActiveEditor
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={onAddLimitAutomation}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
||||
{'priority' in state.template &&
|
||||
typeof state.template.priority === 'number' && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextSubdued,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
<Trans>Priority</Trans>
|
||||
</Text>
|
||||
<Input
|
||||
type="number"
|
||||
style={{ width: 64 }}
|
||||
value={String(state.template.priority)}
|
||||
onChangeValue={value => {
|
||||
if (value === '') return;
|
||||
const parsed = Math.round(Number(value));
|
||||
if (Number.isNaN(parsed)) return;
|
||||
setPriority(Math.max(0, parsed));
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onDelete(activeIdx)}
|
||||
style={{ color: theme.errorText }}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<SvgDelete width={10} height={10} style={{ color: 'inherit' }} />
|
||||
<Trans>Delete automation</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import { AutomationErrorShort } from '#components/budget/goals/automationMessages';
|
||||
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
|
||||
import { TemplateSentence } from '#components/budget/goals/TemplateSentence';
|
||||
import type { AutomationErrorKind } from '#components/budget/goals/validateAutomation';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
|
||||
type AutomationListRowProps = {
|
||||
index: number;
|
||||
entry: AutomationEntry;
|
||||
isActive: boolean;
|
||||
error: AutomationErrorKind | null;
|
||||
contribution: number | null;
|
||||
categoryNameMap: Record<string, string>;
|
||||
onSelect: (index: number) => void;
|
||||
};
|
||||
|
||||
export function AutomationListRow({
|
||||
index,
|
||||
entry,
|
||||
isActive,
|
||||
error,
|
||||
contribution,
|
||||
categoryNameMap,
|
||||
onSelect,
|
||||
}: AutomationListRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const meta = getDisplayTemplateMeta(entry.displayType);
|
||||
const Icon = meta.icon;
|
||||
|
||||
const subtitle = error ? (
|
||||
<AutomationErrorShort error={error} />
|
||||
) : (
|
||||
<TemplateSentence
|
||||
template={entry.template}
|
||||
categoryNameMap={categoryNameMap}
|
||||
/>
|
||||
);
|
||||
|
||||
const borderColor = isActive
|
||||
? theme.tableBorderSelected
|
||||
: error
|
||||
? theme.errorBorder
|
||||
: 'transparent';
|
||||
const backgroundColor = isActive
|
||||
? theme.upcomingBackground
|
||||
: error
|
||||
? theme.errorBackground
|
||||
: 'transparent';
|
||||
const titleColor = error ? theme.errorText : theme.pageText;
|
||||
const subtitleColor = error ? theme.errorText : theme.pageTextSubdued;
|
||||
const priority =
|
||||
'priority' in entry.template && typeof entry.template.priority === 'number'
|
||||
? entry.template.priority
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View
|
||||
onClick={() => onSelect(index)}
|
||||
aria-label={t('Select automation')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: 10,
|
||||
marginBottom: 4,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${borderColor}`,
|
||||
backgroundColor,
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
backgroundColor: error
|
||||
? theme.errorBackground
|
||||
: isActive
|
||||
? theme.upcomingBackground
|
||||
: theme.pillBackground,
|
||||
color: error
|
||||
? theme.errorText
|
||||
: isActive
|
||||
? theme.pageTextPositive
|
||||
: theme.pageTextSubdued,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon width={14} height={14} style={{ color: 'inherit' }} />
|
||||
</View>
|
||||
<View style={{ minWidth: 0, flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: titleColor,
|
||||
}}
|
||||
>
|
||||
<Text>{meta.label}</Text>
|
||||
{error && (
|
||||
<SvgAlertTriangle
|
||||
width={11}
|
||||
height={11}
|
||||
style={{ color: 'inherit' }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: subtitleColor,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
alignItems: 'flex-end',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color:
|
||||
contribution == null ||
|
||||
Number.isNaN(contribution) ||
|
||||
contribution === 0
|
||||
? theme.pageTextSubdued
|
||||
: theme.pageText,
|
||||
}}
|
||||
>
|
||||
{contribution == null || Number.isNaN(contribution)
|
||||
? '—'
|
||||
: contribution > 0
|
||||
? '+' + format(contribution, 'financial')
|
||||
: format(contribution, 'financial')}
|
||||
</Text>
|
||||
{priority != null && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: theme.pageTextSubdued,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{t('Priority: {{priority}}', { priority })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Warning } from '#components/alerts';
|
||||
import { useNotes } from '#hooks/useNotes';
|
||||
|
||||
export function BudgetAutomationMigrationWarning({
|
||||
categoryId,
|
||||
style,
|
||||
}: {
|
||||
categoryId: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const notes = useNotes(categoryId);
|
||||
|
||||
if (!notes) return null;
|
||||
const templates = notes
|
||||
.split('\n')
|
||||
.filter(line => /^\s*#(template|goal|cleanup)\b/.test(line))
|
||||
.join('\n');
|
||||
|
||||
if (!templates) return null;
|
||||
|
||||
return (
|
||||
<Warning
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Imported from notes-based templates. Review and Save to complete the
|
||||
migration.
|
||||
</Trans>
|
||||
</Text>
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer', fontSize: 11, opacity: 0.85 }}>
|
||||
<Trans>Show original templates</Trans>
|
||||
</summary>
|
||||
<View
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
marginTop: 6,
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
// Translucent overlay rather than a theme token so the inset
|
||||
// effect works regardless of the surrounding Warning colour
|
||||
// (which differs between light/dark/midnight themes).
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: 120,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{templates}
|
||||
</View>
|
||||
</details>
|
||||
</View>
|
||||
</Warning>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import type {
|
||||
CategoryGroupEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { css } from '@emotion/css';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import {
|
||||
createAutomationEntry,
|
||||
getAutomationExamples,
|
||||
} from '#components/budget/goals/automationExamples';
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import { formatMonthLabel } from '#components/budget/goals/formatMonthLabel';
|
||||
import {
|
||||
validateAutomation,
|
||||
validatePercentageAllocation,
|
||||
} from '#components/budget/goals/validateAutomation';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { useLocale } from '#hooks/useLocale';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
import { AutomationEditorPane } from './AutomationEditorPane';
|
||||
import { AutomationListRow } from './AutomationListRow';
|
||||
import { BudgetAutomationMigrationWarning } from './BudgetAutomationMigrationWarning';
|
||||
import { ConflictBanner } from './ConflictBanner';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
const RULE_LIST_WIDTH = 310;
|
||||
|
||||
const ALWAYS_SCROLL_CLASS = css({
|
||||
scrollbarGutter: 'stable',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: 11,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
width: 7,
|
||||
minHeight: 24,
|
||||
borderRadius: 30,
|
||||
backgroundClip: 'padding-box',
|
||||
border: '2px solid rgba(0, 0, 0, 0)',
|
||||
backgroundColor: theme.tableBorder,
|
||||
},
|
||||
});
|
||||
|
||||
type BudgetAutomationsBodyProps = {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
needsMigration: boolean;
|
||||
initialEntries: AutomationEntry[];
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
month: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function BudgetAutomationsBody({
|
||||
categoryId,
|
||||
categoryName,
|
||||
needsMigration,
|
||||
initialEntries,
|
||||
schedules,
|
||||
categories,
|
||||
month,
|
||||
onClose,
|
||||
}: BudgetAutomationsBodyProps) {
|
||||
const dispatch = useDispatch();
|
||||
const format = useFormat();
|
||||
const locale = useLocale();
|
||||
|
||||
const [entries, setEntries] = useState<AutomationEntry[]>(initialEntries);
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dryRun, setDryRun] = useState<{
|
||||
budgeted: number;
|
||||
perTemplate: number[];
|
||||
} | null>(null);
|
||||
|
||||
const onAddAutomation = (create?: () => AutomationEntry) => {
|
||||
const fallback = getAutomationExamples().find(
|
||||
e => e.displayType === 'fixed',
|
||||
);
|
||||
const entry = (create ?? fallback?.create)?.();
|
||||
if (!entry) return;
|
||||
setEntries(prev => {
|
||||
const next = [...prev, entry];
|
||||
setActiveIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onAddLimitAutomation = () => {
|
||||
const entry = createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'limit',
|
||||
amount: 500,
|
||||
period: 'monthly',
|
||||
hold: false,
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
);
|
||||
setEntries(prev => [entry, ...prev]);
|
||||
setActiveIdx(0);
|
||||
};
|
||||
|
||||
const onDelete = (index: number) => {
|
||||
setEntries(prev => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
setActiveIdx(currentActive => {
|
||||
if (next.length === 0) return 0;
|
||||
if (currentActive >= next.length) return next.length - 1;
|
||||
if (currentActive > index) return currentActive - 1;
|
||||
return currentActive;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const templatesToSave = entries.map(({ template }) => template);
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [
|
||||
{ id: categoryId, templates: templatesToSave },
|
||||
],
|
||||
source: 'ui',
|
||||
});
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onUnmigrate = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-automations-unmigrate',
|
||||
options: {
|
||||
categoryId,
|
||||
templates: entries.map(({ template }) => template),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const templates = entries.map(e => e.template);
|
||||
|
||||
const validPercentageSources = new Set<string>([
|
||||
'all income',
|
||||
'available funds',
|
||||
]);
|
||||
for (const group of categories) {
|
||||
for (const cat of group.categories ?? []) {
|
||||
if (!cat.is_income) continue;
|
||||
validPercentageSources.add(cat.id);
|
||||
if (cat.name) validPercentageSources.add(cat.name.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const automationErrors = entries.map(entry =>
|
||||
validateAutomation(
|
||||
entry.template,
|
||||
entry.displayType,
|
||||
templates,
|
||||
schedules,
|
||||
new Date(),
|
||||
validPercentageSources,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (templates.length === 0) {
|
||||
setDryRun({ budgeted: 0, perTemplate: [] });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const run = debounce(async () => {
|
||||
try {
|
||||
const result = await send('budget/dry-run-category-template', {
|
||||
month,
|
||||
categoryId,
|
||||
templates,
|
||||
});
|
||||
if (!cancelled) setDryRun(result);
|
||||
} catch {
|
||||
if (!cancelled) setDryRun(null);
|
||||
}
|
||||
}, 200);
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
run.cancel();
|
||||
};
|
||||
}, [templates, month, categoryId]);
|
||||
|
||||
const totalMonthly = dryRun?.budgeted ?? 0;
|
||||
const contributions: (number | null)[] = entries.map((_, i) =>
|
||||
dryRun?.perTemplate?.[i] != null ? dryRun.perTemplate[i] : null,
|
||||
);
|
||||
const hasErrors = automationErrors.some(error => error !== null);
|
||||
const conflict = validatePercentageAllocation(templates);
|
||||
|
||||
const categoryNameMap: Record<string, string> = {};
|
||||
for (const group of categories) {
|
||||
for (const cat of group.categories ?? []) {
|
||||
categoryNameMap[cat.id] = cat.name;
|
||||
}
|
||||
}
|
||||
|
||||
const hasLimitAutomation = entries.some(e => e.displayType === 'limit');
|
||||
|
||||
const safeActiveIdx = Math.min(activeIdx, Math.max(0, entries.length - 1));
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<View
|
||||
style={{
|
||||
padding: '20px 24px 16px',
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<View style={{ minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
|
||||
<Trans>Budget automation</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ textAlign: 'right', flexShrink: 0, minWidth: 220 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
color: theme.pageTextSubdued,
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
Projected for {{ month: formatMonthLabel(month, locale) }}
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: theme.pageTextPositive,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: 1.2,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{format(totalMonthly, 'financial')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{needsMigration && (
|
||||
<BudgetAutomationMigrationWarning
|
||||
categoryId={categoryId}
|
||||
style={{ flexShrink: 0, margin: '12px 24px 0' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{conflict && <ConflictBanner conflict={conflict} />}
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={ALWAYS_SCROLL_CLASS}
|
||||
style={{
|
||||
width: RULE_LIST_WIDTH,
|
||||
borderRight: `1px solid ${theme.tableBorder}`,
|
||||
padding: 10,
|
||||
overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
color: theme.pageTextSubdued,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Automations</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{entries.map((entry, i) => (
|
||||
<AutomationListRow
|
||||
key={entry.id}
|
||||
index={i}
|
||||
entry={entry}
|
||||
isActive={i === safeActiveIdx}
|
||||
error={automationErrors[i]}
|
||||
contribution={contributions[i]}
|
||||
categoryNameMap={categoryNameMap}
|
||||
onSelect={setActiveIdx}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => onAddAutomation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginTop: 8,
|
||||
padding: 10,
|
||||
border: `1px dashed ${theme.tableBorder}`,
|
||||
borderRadius: 6,
|
||||
color: theme.pageTextPositive,
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
+ <Trans>Add an automation</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
{entries.length === 0 ? (
|
||||
<EmptyState onAdd={onAddAutomation} />
|
||||
) : (
|
||||
<AutomationEditorPane
|
||||
entries={entries}
|
||||
activeIdx={safeActiveIdx}
|
||||
automationErrors={automationErrors}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={onAddLimitAutomation}
|
||||
setEntries={setEntries}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: `1px solid ${theme.tableBorder}`,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.tableBackground,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{!needsMigration && (
|
||||
<Link variant="text" onClick={onUnmigrate}>
|
||||
<Trans>Un-migrate to text notes</Trans>
|
||||
</Link>
|
||||
)}
|
||||
<View style={{ flex: 1 }} />
|
||||
<Button onPress={onClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={onSave}
|
||||
isDisabled={hasErrors || conflict !== null || saving}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
|
||||
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
|
||||
|
||||
describe('migrateTemplatesToAutomations', () => {
|
||||
it('preserves simple templates that have no limit and no monthly amount', () => {
|
||||
it('drops simple templates that have no limit and no monthly amount', () => {
|
||||
// these would otherwise be pushed as a phantom 'fixed' entry that
|
||||
// crashes FixedAutomationReadOnly (no .amount, no .period)
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 5,
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].displayType).toBe('week');
|
||||
expect(result[0].template).toEqual(simpleTemplate);
|
||||
expect(result[0].id).toMatch(/^automation-/);
|
||||
it('drops simple templates whose monthly amount is zero with no limit', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 5,
|
||||
monthly: 0,
|
||||
} satisfies Template;
|
||||
|
||||
expect(migrateTemplatesToAutomations([simpleTemplate])).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws when a goal directive reaches migration', () => {
|
||||
const goalTemplate = {
|
||||
type: 'goal',
|
||||
amount: 1000,
|
||||
directive: 'goal',
|
||||
} satisfies Template;
|
||||
|
||||
expect(() => migrateTemplatesToAutomations([goalTemplate])).toThrow(
|
||||
/Unsupported template type/,
|
||||
);
|
||||
});
|
||||
|
||||
it('expands a simple template with limit into limit and refill entries', () => {
|
||||
@@ -63,7 +83,7 @@ describe('migrateTemplatesToAutomations', () => {
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].displayType).toBe('week');
|
||||
expect(result[0].displayType).toBe('fixed');
|
||||
expect(result[0].template).toMatchObject({
|
||||
type: 'periodic',
|
||||
amount: 45,
|
||||
@@ -79,7 +99,10 @@ describe('migrateTemplatesToAutomations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a simple template with both limit and monthly into three entries in order', () => {
|
||||
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
|
||||
// `#template 20 up to 200 per week` budgets 20/month and caps at the
|
||||
// limit — the engine's runSimple returns just the monthly value, so
|
||||
// there is no implicit refill-to-cap behaviour to migrate.
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
@@ -94,13 +117,9 @@ describe('migrateTemplatesToAutomations', () => {
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(entry => entry.displayType)).toEqual([
|
||||
'limit',
|
||||
'refill',
|
||||
'week',
|
||||
]);
|
||||
expect(result[2].template).toMatchObject({
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
|
||||
expect(result[1].template).toMatchObject({
|
||||
type: 'periodic',
|
||||
amount: 20,
|
||||
directive: 'template',
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { currentMonth } from '@actual-app/core/shared/months';
|
||||
import { q } from '@actual-app/core/shared/query';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { useBudgetAutomationCategories } from '#components/budget/goals/useBudgetAutomationCategories';
|
||||
import { Modal } from '#components/common/Modal';
|
||||
import { useBudgetAutomations } from '#hooks/useBudgetAutomations';
|
||||
import { useCategory } from '#hooks/useCategory';
|
||||
import { useNotes } from '#hooks/useNotes';
|
||||
import { useSchedules } from '#hooks/useSchedules';
|
||||
|
||||
import { BudgetAutomationsBody } from './BudgetAutomationsBody';
|
||||
import { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
|
||||
import {
|
||||
hasCleanupLine,
|
||||
UnsupportedDirectivesNotice,
|
||||
} from './UnsupportedDirectivesNotice';
|
||||
|
||||
const MODAL_WIDTH = 960;
|
||||
const MODAL_HEIGHT = 760;
|
||||
|
||||
export function BudgetAutomationsModal({
|
||||
categoryId,
|
||||
month,
|
||||
}: {
|
||||
categoryId: string;
|
||||
month?: string;
|
||||
}) {
|
||||
const [parsedTemplates, setParsedTemplates] = useState<Template[] | null>(
|
||||
null,
|
||||
);
|
||||
const effectiveMonth = month ?? currentMonth();
|
||||
|
||||
const onLoaded = (result: Record<string, Template[]>) => {
|
||||
setParsedTemplates(result[categoryId] ?? []);
|
||||
};
|
||||
|
||||
const { loading } = useBudgetAutomations({ categoryId, onLoaded });
|
||||
|
||||
const { schedules } = useSchedules({ query: q('schedules').select('*') });
|
||||
|
||||
const categories = useBudgetAutomationCategories();
|
||||
const { data: currentCategory } = useCategory(categoryId);
|
||||
const notes = useNotes(categoryId);
|
||||
|
||||
const needsMigration = currentCategory?.template_settings?.source !== 'ui';
|
||||
|
||||
const hasGoalTemplate =
|
||||
parsedTemplates?.some(t => t.type === 'goal') ?? false;
|
||||
const hasErrorTemplate =
|
||||
parsedTemplates?.some(t => t.type === 'error') ?? false;
|
||||
const hasSpendTemplate =
|
||||
parsedTemplates?.some(t => t.type === 'spend') ?? false;
|
||||
// Only surface stale `#cleanup` lines for categories that haven't been
|
||||
// migrated to UI-managed automations; once `source === 'ui'`, the notes
|
||||
// are no longer the source of truth.
|
||||
const hasCleanupDirective = needsMigration && hasCleanupLine(notes);
|
||||
const hasUnsupportedDirective =
|
||||
hasGoalTemplate ||
|
||||
hasErrorTemplate ||
|
||||
hasSpendTemplate ||
|
||||
hasCleanupDirective;
|
||||
|
||||
const incomeNameToId = new Map<string, string>();
|
||||
for (const group of categories) {
|
||||
for (const cat of group.categories ?? []) {
|
||||
if (cat.name) incomeNameToId.set(cat.name.toLowerCase(), cat.id);
|
||||
}
|
||||
}
|
||||
const resolved = parsedTemplates?.map(t => {
|
||||
if (t.type !== 'percentage' || !t.category) return t;
|
||||
const id = incomeNameToId.get(t.category.toLowerCase());
|
||||
return id ? { ...t, category: id } : t;
|
||||
});
|
||||
const initialEntries =
|
||||
resolved && !hasUnsupportedDirective
|
||||
? migrateTemplatesToAutomations(resolved)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="category-automations-edit"
|
||||
containerProps={{
|
||||
style: {
|
||||
width: MODAL_WIDTH,
|
||||
maxWidth: '95vw',
|
||||
height: MODAL_HEIGHT,
|
||||
maxHeight: '90vh',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state }) => (
|
||||
<View style={{ flex: 1, minHeight: 0 }}>
|
||||
{loading || parsedTemplates === null ? (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
</View>
|
||||
) : hasUnsupportedDirective ? (
|
||||
<UnsupportedDirectivesNotice
|
||||
hasGoalTemplate={hasGoalTemplate}
|
||||
hasErrorTemplate={hasErrorTemplate}
|
||||
hasSpendTemplate={hasSpendTemplate}
|
||||
hasCleanupDirective={hasCleanupDirective}
|
||||
onClose={() => state.close()}
|
||||
/>
|
||||
) : (
|
||||
<BudgetAutomationsBody
|
||||
categoryId={categoryId}
|
||||
categoryName={currentCategory?.name ?? ''}
|
||||
needsMigration={needsMigration}
|
||||
initialEntries={initialEntries ?? []}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
month={effectiveMonth}
|
||||
onClose={() => state.close()}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import {
|
||||
GlobalConflictDetail,
|
||||
GlobalConflictTitle,
|
||||
} from '#components/budget/goals/automationMessages';
|
||||
import type { GlobalConflictKind } from '#components/budget/goals/validateAutomation';
|
||||
|
||||
type ConflictBannerProps = {
|
||||
conflict: GlobalConflictKind;
|
||||
};
|
||||
|
||||
export function ConflictBanner({ conflict }: ConflictBannerProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: '8px 22px',
|
||||
backgroundColor: theme.errorBackground,
|
||||
borderBottom: `1px solid ${theme.errorBorder}`,
|
||||
color: theme.errorText,
|
||||
fontSize: 12,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<SvgAlertTriangle width={14} height={14} style={{ color: 'inherit' }} />
|
||||
<Text style={{ color: 'inherit' }}>
|
||||
<strong>
|
||||
<GlobalConflictTitle conflict={conflict} />.
|
||||
</strong>{' '}
|
||||
<GlobalConflictDetail conflict={conflict} />
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { getAutomationExamples } from '#components/budget/goals/automationExamples';
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
|
||||
|
||||
type EmptyStateProps = {
|
||||
onAdd: (create: () => AutomationEntry) => void;
|
||||
};
|
||||
|
||||
export function EmptyState({ onAdd }: EmptyStateProps) {
|
||||
const examples = getAutomationExamples();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
maxWidth: 540,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 12,
|
||||
margin: '0 auto 14px',
|
||||
backgroundColor: theme.upcomingBackground,
|
||||
color: theme.pageTextPositive,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<SvgAlertTriangle width={20} height={20} style={{ color: 'inherit' }} />
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
<Trans>No automations yet</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
marginTop: 4,
|
||||
marginBottom: 22,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
Budget automations keep this category funded with one click each
|
||||
month. Start with one of these.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{examples.map(example => {
|
||||
const meta = getDisplayTemplateMeta(example.displayType);
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<View
|
||||
key={example.displayType}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={meta.label}
|
||||
onClick={() => onAdd(example.create)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onAdd(example.create);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 8,
|
||||
backgroundColor: theme.cardBackground,
|
||||
border: `1px solid ${theme.tableBorder}`,
|
||||
gap: 6,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
backgroundColor: theme.upcomingBackground,
|
||||
color: theme.pageTextPositive,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Icon width={16} height={16} />
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: theme.pageTextSubdued,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{meta.description}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { displayTemplateTypes } from '#components/budget/goals/constants';
|
||||
import type { DisplayTemplateType } from '#components/budget/goals/constants';
|
||||
import { getDisplayTemplateMeta } from '#components/budget/goals/displayTemplateMeta';
|
||||
|
||||
type TypePickerProps = {
|
||||
active: DisplayTemplateType;
|
||||
disabledTypes: ReadonlySet<DisplayTemplateType>;
|
||||
onPick: (type: DisplayTemplateType) => void;
|
||||
};
|
||||
|
||||
export function TypePicker({ active, disabledTypes, onPick }: TypePickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const entries = displayTemplateTypes.map(
|
||||
id => [id, getDisplayTemplateMeta(id)] as const,
|
||||
);
|
||||
const disabledHint = t('Only one of this type allowed per category');
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{entries.map(([id, meta]) => {
|
||||
const Icon = meta.icon;
|
||||
const isActive = id === active;
|
||||
const isDisabled = !isActive && disabledTypes.has(id);
|
||||
return (
|
||||
<View
|
||||
key={id}
|
||||
role="button"
|
||||
tabIndex={isDisabled ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
aria-disabled={isDisabled}
|
||||
title={isDisabled ? disabledHint : undefined}
|
||||
onClick={() => {
|
||||
if (!isDisabled) onPick(id);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onPick(id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 10px 8px',
|
||||
borderRadius: 6,
|
||||
backgroundColor: isActive
|
||||
? theme.upcomingBackground
|
||||
: theme.cardBackground,
|
||||
border: `1px solid ${isActive ? theme.pageTextPositive : theme.tableBorder}`,
|
||||
gap: 6,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.45 : 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
width={16}
|
||||
height={16}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: isActive ? theme.pageTextPositive : theme.pageText,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: isActive ? theme.pageTextPositive : theme.pageText,
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 11,
|
||||
color: theme.pageTextSubdued,
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{meta.description}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
export function UnsupportedDirectivesNotice({
|
||||
hasGoalTemplate,
|
||||
hasErrorTemplate,
|
||||
hasSpendTemplate,
|
||||
hasCleanupDirective,
|
||||
onClose,
|
||||
}: {
|
||||
hasGoalTemplate: boolean;
|
||||
hasErrorTemplate: boolean;
|
||||
hasSpendTemplate: boolean;
|
||||
hasCleanupDirective: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<SvgAlertTriangle
|
||||
width={32}
|
||||
height={32}
|
||||
style={{ color: theme.errorText }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: theme.pageText,
|
||||
}}
|
||||
>
|
||||
<Trans>This category isn’t supported in the UI yet</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.pageTextSubdued,
|
||||
maxWidth: 480,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{hasErrorTemplate ? (
|
||||
<Trans>
|
||||
One or more <code>#template</code> lines in this category’s
|
||||
notes couldn’t be parsed. Fix them as text first, then re-open
|
||||
this modal to migrate.
|
||||
</Trans>
|
||||
) : hasSpendTemplate ? (
|
||||
<Trans>
|
||||
This category uses a <code>spend from</code> template, which the
|
||||
budget automations UI doesn’t handle yet. Keep editing it as
|
||||
text in the category’s notes.
|
||||
</Trans>
|
||||
) : hasGoalTemplate && hasCleanupDirective ? (
|
||||
<Trans>
|
||||
This category’s notes use <code>#goal</code> and{' '}
|
||||
<code>#cleanup</code> directives, neither of which the budget
|
||||
automations UI handles yet. Keep editing them as text in the
|
||||
category’s notes.
|
||||
</Trans>
|
||||
) : hasGoalTemplate ? (
|
||||
<Trans>
|
||||
This category uses a <code>#goal</code> directive, which the budget
|
||||
automations UI doesn’t handle yet. Keep editing it as text in
|
||||
the category’s notes.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
This category uses a <code>#cleanup</code> directive, which the
|
||||
budget automations UI doesn’t handle yet. Keep editing it as
|
||||
text in the category’s notes.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Button onPress={onClose}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CLEANUP_DIRECTIVE = /^\s*#cleanup\b/;
|
||||
|
||||
export function hasCleanupLine(notes: string | null | undefined): boolean {
|
||||
if (!notes) return false;
|
||||
return notes.split('\n').some(line => CLEANUP_DIRECTIVE.test(line));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BudgetAutomationsModal } from './BudgetAutomationsModal';
|
||||
export { migrateTemplatesToAutomations } from './migrateTemplatesToAutomations';
|
||||
@@ -0,0 +1,111 @@
|
||||
import { dayFromDate, firstDayOfMonth } from '@actual-app/core/shared/months';
|
||||
import type { Template } from '@actual-app/core/types/models/templates';
|
||||
|
||||
import { createAutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
|
||||
import type { DisplayTemplateType } from '#components/budget/goals/constants';
|
||||
|
||||
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
|
||||
switch (template.type) {
|
||||
case 'percentage':
|
||||
return 'percentage';
|
||||
case 'schedule':
|
||||
return 'schedule';
|
||||
case 'periodic':
|
||||
case 'simple':
|
||||
return 'fixed';
|
||||
case 'limit':
|
||||
return 'limit';
|
||||
case 'refill':
|
||||
return 'refill';
|
||||
case 'average':
|
||||
case 'copy':
|
||||
return 'historical';
|
||||
case 'by':
|
||||
return 'by';
|
||||
case 'remainder':
|
||||
return 'remainder';
|
||||
case 'goal':
|
||||
case 'error':
|
||||
case 'spend':
|
||||
// filtered upstream by hasUnsupportedDirective; surface if it ever isn't
|
||||
throw new Error(`Unsupported template type reached migration`);
|
||||
default: {
|
||||
const _exhaustive: never = template;
|
||||
void _exhaustive;
|
||||
throw new Error(`Unhandled template type`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateTemplatesToAutomations(
|
||||
templates: Template[],
|
||||
): AutomationEntry[] {
|
||||
const entries: AutomationEntry[] = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
if (template.type === 'simple') {
|
||||
const monthly = template.monthly;
|
||||
const hasMonthly = monthly != null && monthly !== 0;
|
||||
|
||||
if (template.limit) {
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'limit',
|
||||
amount: template.limit.amount,
|
||||
hold: template.limit.hold,
|
||||
period: template.limit.period,
|
||||
start: template.limit.start,
|
||||
directive: 'template',
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
),
|
||||
);
|
||||
// The implicit refill only applies to a limit-only simple template
|
||||
// (e.g. `#template up to 200`). When a monthly amount is also set
|
||||
// (`#template 50 up to 200`), the engine just budgets the monthly
|
||||
// amount and clamps to the cap — no top-up to the limit.
|
||||
if (!hasMonthly) {
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'refill',
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'refill',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hasMonthly) {
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: monthly,
|
||||
period: { period: 'month', amount: 1 },
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'fixed',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// a simple template with neither monthly nor limit is a no-op; drop it
|
||||
// rather than passing through as a phantom 'fixed' entry that would
|
||||
// crash FixedAutomationReadOnly (no .amount, no .period)
|
||||
return;
|
||||
}
|
||||
|
||||
entries.push(
|
||||
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
|
||||
);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -1,32 +1,20 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog, DialogTrigger } from 'react-aria-components';
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgDotsHorizontalTriple } from '@actual-app/components/icons/v1';
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
|
||||
import { useAuth } from '#auth/AuthProvider';
|
||||
import { Permissions } from '#auth/types';
|
||||
import { Warning } from '#components/alerts';
|
||||
import { BuiltInProviders } from '#components/banksync/BuiltInProviders';
|
||||
import { useBuiltInBankSyncProviders } from '#components/banksync/useBuiltInBankSyncProviders';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { useMultiuserEnabled } from '#components/ServerContext';
|
||||
import { authorizeBank } from '#gocardless';
|
||||
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
|
||||
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
|
||||
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
|
||||
import { useSyncServerStatus } from '#hooks/useSyncServerStatus';
|
||||
import { useNavigate } from '#hooks/useNavigate';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import type { Modal as ModalType } from '#modals/modalsSlice';
|
||||
import { addNotification } from '#notifications/notificationsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
type CreateAccountModalProps = Extract<
|
||||
@@ -38,296 +26,25 @@ export function CreateAccountModal({
|
||||
upgradingAccountId,
|
||||
}: CreateAccountModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const dispatch = useDispatch();
|
||||
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const { hasPermission } = useAuth();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
|
||||
const onConnectGoCardless = () => {
|
||||
if (!isGoCardlessSetupComplete) {
|
||||
onGoCardlessInit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (upgradingAccountId == null) {
|
||||
void authorizeBank(dispatch);
|
||||
} else {
|
||||
void authorizeBank(dispatch);
|
||||
}
|
||||
};
|
||||
|
||||
const onConnectSimpleFin = async () => {
|
||||
if (!isSimpleFinSetupComplete) {
|
||||
onSimpleFinInit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingSimpleFinAccounts) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSimpleFinAccounts(true);
|
||||
|
||||
try {
|
||||
const results = await send('simplefin-accounts');
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
}
|
||||
|
||||
const newAccounts = [];
|
||||
|
||||
type NormalizedAccount = {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
orgDomain: string;
|
||||
orgId: string;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
for (const oldAccount of results.accounts ?? []) {
|
||||
const newAccount: NormalizedAccount = {
|
||||
account_id: oldAccount.id,
|
||||
name: oldAccount.name,
|
||||
institution: oldAccount.org.name,
|
||||
orgDomain: oldAccount.org.domain,
|
||||
orgId: oldAccount.org.id,
|
||||
balance: oldAccount.balance,
|
||||
};
|
||||
|
||||
newAccounts.push(newAccount);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: newAccounts,
|
||||
syncSource: 'simpleFin',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setLoadingSimpleFinAccounts(false);
|
||||
};
|
||||
|
||||
const onConnectPluggyAi = async () => {
|
||||
if (!isPluggyAiSetupComplete) {
|
||||
onPluggyAiInit();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await send('pluggyai-accounts');
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
} else if ('error' in results) {
|
||||
throw new Error(results.error);
|
||||
}
|
||||
|
||||
const newAccounts = [];
|
||||
|
||||
type NormalizedAccount = {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
orgDomain: string | null;
|
||||
orgId: string;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
for (const oldAccount of results.accounts) {
|
||||
const newAccount: NormalizedAccount = {
|
||||
account_id: oldAccount.id,
|
||||
name: `${oldAccount.name.trim()} - ${oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner}`,
|
||||
institution: oldAccount.name,
|
||||
orgDomain: null,
|
||||
orgId: oldAccount.id,
|
||||
balance:
|
||||
oldAccount.type === 'BANK'
|
||||
? oldAccount.bankData.automaticallyInvestedBalance +
|
||||
oldAccount.bankData.closingBalance
|
||||
: oldAccount.balance,
|
||||
};
|
||||
|
||||
newAccounts.push(newAccount);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: newAccounts,
|
||||
syncSource: 'pluggyai',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error when trying to contact Pluggy.ai'),
|
||||
message: (err as Error).message,
|
||||
timeout: 5000,
|
||||
},
|
||||
});
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => setIsPluggyAiSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onGoCardlessInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'gocardless-init',
|
||||
options: {
|
||||
onSuccess: () => setIsGoCardlessSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onSimpleFinInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onPluggyAiInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'pluggyai-init',
|
||||
options: {
|
||||
onSuccess: () => setIsPluggyAiSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onGoCardlessReset = () => {
|
||||
void send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
void send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
setIsGoCardlessSetupComplete(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onSimpleFinReset = () => {
|
||||
void send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
void send('secret-set', {
|
||||
name: 'simplefin_accessKey',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
setIsSimpleFinSetupComplete(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onPluggyAiReset = () => {
|
||||
void send('secret-set', {
|
||||
name: 'pluggyai_clientId',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
void send('secret-set', {
|
||||
name: 'pluggyai_clientSecret',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
void send('secret-set', {
|
||||
name: 'pluggyai_itemIds',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
setIsPluggyAiSetupComplete(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
providers,
|
||||
syncServerStatus,
|
||||
showPermissionWarning,
|
||||
providersNeedingConfiguration,
|
||||
} = useBuiltInBankSyncProviders({ upgradingAccountId });
|
||||
|
||||
const onCreateLocalAccount = () => {
|
||||
dispatch(pushModal({ modal: { name: 'add-local-account' } }));
|
||||
};
|
||||
|
||||
const { configuredGoCardless } = useGoCardlessStatus();
|
||||
useEffect(() => {
|
||||
setIsGoCardlessSetupComplete(configuredGoCardless);
|
||||
}, [configuredGoCardless]);
|
||||
|
||||
const { configuredSimpleFin } = useSimpleFinStatus();
|
||||
useEffect(() => {
|
||||
setIsSimpleFinSetupComplete(configuredSimpleFin);
|
||||
}, [configuredSimpleFin]);
|
||||
|
||||
const { configuredPluggyAi } = usePluggyAiStatus();
|
||||
useEffect(() => {
|
||||
setIsPluggyAiSetupComplete(configuredPluggyAi);
|
||||
}, [configuredPluggyAi]);
|
||||
|
||||
let title = t('Add account');
|
||||
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
|
||||
useState(false);
|
||||
|
||||
if (upgradingAccountId != null) {
|
||||
title = t('Link account');
|
||||
}
|
||||
|
||||
const canSetSecrets =
|
||||
!multiuserEnabled || hasPermission(Permissions.ADMINISTRATOR);
|
||||
|
||||
return (
|
||||
<Modal name="add-account">
|
||||
{({ state }) => (
|
||||
@@ -336,266 +53,69 @@ export function CreateAccountModal({
|
||||
title={title}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
|
||||
{upgradingAccountId == null && (
|
||||
<View style={{ gap: 10 }}>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onPress={onCreateLocalAccount}
|
||||
>
|
||||
<Trans>Create a local account</Trans>
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
<strong>Create a local account</strong> if you want to add
|
||||
transactions manually. You can also{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/transactions/importing"
|
||||
linkColor="muted"
|
||||
>
|
||||
import QIF/OFX/QFX files into a local account
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ gap: 10 }}>
|
||||
{syncServerStatus === 'online' ? (
|
||||
<>
|
||||
{canSetSecrets && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectGoCardless}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: upgradingAccountId == null ? 500 : 720,
|
||||
gap: 24,
|
||||
color: theme.pageText,
|
||||
}}
|
||||
>
|
||||
{upgradingAccountId != null ? (
|
||||
<>
|
||||
<Paragraph
|
||||
style={{ fontSize: 15, color: theme.pageTextSubdued }}
|
||||
>
|
||||
<Trans>
|
||||
Choose a bank sync provider to connect this account.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
<BuiltInProviders
|
||||
providers={providers}
|
||||
syncServerStatus={syncServerStatus}
|
||||
showPermissionWarning={showPermissionWarning}
|
||||
providersNeedingConfiguration={providersNeedingConfiguration}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View style={{ gap: 10 }}>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onPress={onCreateLocalAccount}
|
||||
>
|
||||
<Trans>Create a local account</Trans>
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
<strong>Create a local account</strong> if you want to
|
||||
add transactions manually. You can also{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/transactions/importing"
|
||||
linkColor="muted"
|
||||
>
|
||||
{isGoCardlessSetupComplete
|
||||
? t('Link bank account with GoCardless')
|
||||
: t('Set up GoCardless for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isGoCardlessSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('GoCardless menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onGoCardlessReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset GoCardless credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>European</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. GoCardless
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: '18px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
isLoading={loadingSimpleFinAccounts}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectSimpleFin}
|
||||
>
|
||||
{isSimpleFinSetupComplete
|
||||
? t('Link bank account with SimpleFIN')
|
||||
: t('Set up SimpleFIN for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isSimpleFinSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('SimpleFIN menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onSimpleFinReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset SimpleFIN credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>North American</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. SimpleFIN
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={syncServerStatus !== 'online'}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectPluggyAi}
|
||||
>
|
||||
{isPluggyAiSetupComplete
|
||||
? t('Link bank account with Pluggy.ai')
|
||||
: t('Set up Pluggy.ai for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isPluggyAiSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Pluggy.ai menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onPluggyAiReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t('Reset Pluggy.ai credentials'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>Brazilian</em> bank account
|
||||
</strong>{' '}
|
||||
to automatically download transactions. Pluggy.ai
|
||||
provides reliable, up-to-date information from
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!isGoCardlessSetupComplete ||
|
||||
!isSimpleFinSetupComplete ||
|
||||
!isPluggyAiSetupComplete) &&
|
||||
!canSetSecrets && (
|
||||
<Warning>
|
||||
<Trans>
|
||||
You don't have the required permissions to set up
|
||||
secrets. Please contact an Admin to configure
|
||||
</Trans>{' '}
|
||||
{[
|
||||
isGoCardlessSetupComplete ? '' : 'GoCardless',
|
||||
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
|
||||
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' or ')}
|
||||
import QIF/OFX/QFX files into a local account
|
||||
</Link>
|
||||
.
|
||||
</Warning>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ gap: 10 }}>
|
||||
<Button
|
||||
isDisabled
|
||||
onPress={() => {
|
||||
state.close();
|
||||
void navigate('/bank-sync');
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
@@ -604,22 +124,17 @@ export function CreateAccountModal({
|
||||
>
|
||||
<Trans>Set up bank sync</Trans>
|
||||
</Button>
|
||||
<Paragraph style={{ fontSize: 15 }}>
|
||||
<Paragraph
|
||||
style={{ fontSize: 15, color: theme.pageTextSubdued }}
|
||||
>
|
||||
<Trans>
|
||||
Connect to an Actual server to set up{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/advanced/bank-sync"
|
||||
linkColor="muted"
|
||||
>
|
||||
automatic syncing
|
||||
</Link>
|
||||
.
|
||||
Configure providers and link accounts from the Bank Sync
|
||||
page.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -74,22 +74,26 @@ export type SelectLinkedAccountsModalProps =
|
||||
requisitionId: string;
|
||||
externalAccounts: SyncServerGoCardlessAccount[];
|
||||
syncSource: 'goCardless';
|
||||
upgradingAccountId?: string;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerSimpleFinAccount[];
|
||||
syncSource: 'simpleFin';
|
||||
upgradingAccountId?: string;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerPluggyAiAccount[];
|
||||
syncSource: 'pluggyai';
|
||||
upgradingAccountId?: string;
|
||||
};
|
||||
|
||||
export function SelectLinkedAccountsModal({
|
||||
requisitionId = undefined,
|
||||
externalAccounts,
|
||||
syncSource,
|
||||
upgradingAccountId,
|
||||
}: SelectLinkedAccountsModalProps) {
|
||||
const propsWithSortedExternalAccounts =
|
||||
useMemo<SelectLinkedAccountsModalProps>(() => {
|
||||
@@ -104,22 +108,25 @@ export function SelectLinkedAccountsModal({
|
||||
return {
|
||||
syncSource: 'simpleFin',
|
||||
externalAccounts: toSort as SyncServerSimpleFinAccount[],
|
||||
upgradingAccountId,
|
||||
};
|
||||
case 'pluggyai':
|
||||
return {
|
||||
syncSource: 'pluggyai',
|
||||
externalAccounts: toSort as SyncServerPluggyAiAccount[],
|
||||
upgradingAccountId,
|
||||
};
|
||||
case 'goCardless':
|
||||
return {
|
||||
syncSource: 'goCardless',
|
||||
requisitionId: requisitionId!,
|
||||
externalAccounts: toSort as SyncServerGoCardlessAccount[],
|
||||
upgradingAccountId,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unrecognized sync source: ${String(syncSource)}`);
|
||||
}
|
||||
}, [externalAccounts, syncSource, requisitionId]);
|
||||
}, [externalAccounts, syncSource, requisitionId, upgradingAccountId]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
@@ -140,11 +147,27 @@ export function SelectLinkedAccountsModal({
|
||||
});
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
return Object.fromEntries(
|
||||
const initiallyChosenAccounts = Object.fromEntries(
|
||||
localAccounts
|
||||
.filter(acc => acc.account_id)
|
||||
.map(acc => [acc.account_id, acc.id]),
|
||||
);
|
||||
|
||||
const preselectedExternalAccount =
|
||||
propsWithSortedExternalAccounts.externalAccounts.find(
|
||||
account => initiallyChosenAccounts[account.account_id] == null,
|
||||
);
|
||||
|
||||
if (
|
||||
upgradingAccountId &&
|
||||
preselectedExternalAccount &&
|
||||
!Object.values(initiallyChosenAccounts).includes(upgradingAccountId)
|
||||
) {
|
||||
initiallyChosenAccounts[preselectedExternalAccount.account_id] =
|
||||
upgradingAccountId;
|
||||
}
|
||||
|
||||
return initiallyChosenAccounts;
|
||||
},
|
||||
);
|
||||
const [customStartingDates, setCustomStartingDates] = useState<
|
||||
|
||||
@@ -11,9 +11,26 @@ import type { Template } from '@actual-app/core/types/models/templates';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { Notes } from '#components/Notes';
|
||||
import { useCategories } from '#hooks/useCategories';
|
||||
import { useCategory } from '#hooks/useCategory';
|
||||
import { useNotes } from '#hooks/useNotes';
|
||||
|
||||
// The UI's CategoryAutocomplete stores the income category id on a
|
||||
// percentage template, but text-template grammar addresses categories by
|
||||
// name. Rewrite percentage templates so the un-migrated notes are readable
|
||||
// (and don't drift if the category is later renamed).
|
||||
function sanitizePercentageCategoriesForNotes(
|
||||
templates: Template[],
|
||||
idToName: Map<string, string>,
|
||||
): Template[] {
|
||||
return templates.map(template => {
|
||||
if (template.type !== 'percentage') return template;
|
||||
const name = idToName.get(template.category);
|
||||
if (name) return { ...template, category: name };
|
||||
return template;
|
||||
});
|
||||
}
|
||||
|
||||
export function UnmigrateBudgetAutomationsModal({
|
||||
categoryId,
|
||||
templates,
|
||||
@@ -23,6 +40,7 @@ export function UnmigrateBudgetAutomationsModal({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: category } = useCategory(categoryId);
|
||||
const { data: categoryData } = useCategories();
|
||||
const existingNotes = useNotes(categoryId) || '';
|
||||
const [editedNotes, setEditedNotes] = useState<string>('');
|
||||
|
||||
@@ -30,12 +48,18 @@ export function UnmigrateBudgetAutomationsModal({
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoryData?.list) return;
|
||||
const idToName = new Map<string, string>();
|
||||
for (const cat of categoryData.list) {
|
||||
idToName.set(cat.id, cat.name);
|
||||
}
|
||||
const sanitized = sanitizePercentageCategoriesForNotes(templates, idToName);
|
||||
let mounted = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const text: string = await send(
|
||||
'budget/render-note-templates',
|
||||
templates,
|
||||
sanitized,
|
||||
);
|
||||
if (mounted) setRendered(text);
|
||||
} catch {
|
||||
@@ -45,7 +69,7 @@ export function UnmigrateBudgetAutomationsModal({
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [templates]);
|
||||
}, [templates, categoryData]);
|
||||
|
||||
// Seed editable notes once templates rendered
|
||||
useEffect(() => {
|
||||
@@ -87,13 +111,21 @@ export function UnmigrateBudgetAutomationsModal({
|
||||
|
||||
async function onSave(close: () => void) {
|
||||
setSaving(true);
|
||||
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [{ id: categoryId, templates }],
|
||||
source: 'notes',
|
||||
});
|
||||
setSaving(false);
|
||||
close();
|
||||
try {
|
||||
await send('notes-save-undoable', { id: categoryId, note: editedNotes });
|
||||
// Hand control back to the notes parser: clear the UI-managed goal_def
|
||||
// and mark notes as the source of truth. `storeNoteTemplates` will
|
||||
// re-derive goal_def from the notes the next time it runs (e.g. on
|
||||
// modal open or when applying templates).
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [{ id: categoryId, templates: [] }],
|
||||
source: 'notes',
|
||||
});
|
||||
await send('budget/store-note-templates');
|
||||
close();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateSpendingReportTimeRange } from './reportRanges';
|
||||
|
||||
// In test mode, monthUtils.currentMonth() returns '2017-01'
|
||||
describe('calculateSpendingReportTimeRange', () => {
|
||||
it('preserves the saved compare month for live average reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
isLive: true,
|
||||
mode: 'average',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-12');
|
||||
});
|
||||
|
||||
it('preserves the saved compare month for live budget reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
isLive: true,
|
||||
mode: 'budget',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-12');
|
||||
});
|
||||
|
||||
it('preserves the saved compare months for live single month reports', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
compare: '2016-12',
|
||||
compareTo: '2016-11',
|
||||
isLive: true,
|
||||
mode: 'single-month',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2016-12');
|
||||
expect(compareTo).toBe('2016-11');
|
||||
});
|
||||
|
||||
it('defaults live average reports to the current month without a saved compare month', () => {
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange({
|
||||
isLive: true,
|
||||
mode: 'average',
|
||||
});
|
||||
|
||||
expect(compare).toBe('2017-01');
|
||||
expect(compareTo).toBe('2017-01');
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,12 @@ export function calculateSpendingReportTimeRange({
|
||||
mode?: 'budget' | 'average' | 'single-month';
|
||||
}): [string, string] {
|
||||
if (['budget', 'average'].includes(mode) && isLive) {
|
||||
return [monthUtils.currentMonth(), monthUtils.currentMonth()];
|
||||
const month = compare ?? monthUtils.currentMonth();
|
||||
return [month, month];
|
||||
}
|
||||
|
||||
if (mode === 'single-month' && isLive && compare) {
|
||||
return [compare, compareTo ?? monthUtils.subMonths(compare, 1)];
|
||||
}
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
|
||||
@@ -415,12 +415,16 @@ function DateSelectDesktop({
|
||||
inputProps?.onBlur?.(e);
|
||||
|
||||
if (clearOnBlur) {
|
||||
// If value is empty, that drives what gets selected.
|
||||
// Otherwise the input is reset to whatever is already
|
||||
// selected
|
||||
// If value is empty, reset to previously selected value
|
||||
// instead of saving an empty date (which the server rejects).
|
||||
if (value === '') {
|
||||
setSelectedValue('');
|
||||
onSelect('');
|
||||
if (selectedValue) {
|
||||
setValue(selectedValue);
|
||||
const date = parse(selectedValue, dateFormat, new Date());
|
||||
if (date instanceof Date && !isNaN(date.valueOf())) {
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setValue(selectedValue || '');
|
||||
|
||||
|
||||
@@ -151,10 +151,7 @@ export function ExperimentalFeatures() {
|
||||
|
||||
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
|
||||
const showGoalTemplatesUI =
|
||||
goalTemplatesUIEnabled ||
|
||||
(goalTemplatesEnabled &&
|
||||
localStorage.getItem('devEnableGoalTemplatesUI') === 'true');
|
||||
const showGoalTemplatesUI = goalTemplatesEnabled || goalTemplatesUIEnabled;
|
||||
|
||||
const showServerPrefs =
|
||||
localStorage.getItem('devEnableServerPrefs') === 'true';
|
||||
@@ -169,7 +166,10 @@ export function ExperimentalFeatures() {
|
||||
</FeatureToggle>
|
||||
{showGoalTemplatesUI && (
|
||||
<View style={{ paddingLeft: 22 }}>
|
||||
<FeatureToggle flag="goalTemplatesUIEnabled">
|
||||
<FeatureToggle
|
||||
flag="goalTemplatesUIEnabled"
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/7692"
|
||||
>
|
||||
<Trans>Subfeature: Budget automations UI</Trans>
|
||||
</FeatureToggle>
|
||||
</View>
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
generateThemeId,
|
||||
validateThemeCss,
|
||||
} from '#style/customThemes';
|
||||
import type { CatalogTheme } from '#style/customThemes';
|
||||
|
||||
import { ThemeInstaller } from './ThemeInstaller';
|
||||
|
||||
const render: typeof rtlRender = (ui, options) =>
|
||||
rtlRender(ui, { wrapper: TestProviders, ...options });
|
||||
function render(
|
||||
ui: Parameters<typeof rtlRender>[0],
|
||||
options?: Parameters<typeof rtlRender>[1],
|
||||
) {
|
||||
return rtlRender(ui, { wrapper: TestProviders, ...options });
|
||||
}
|
||||
|
||||
vi.mock('#style/customThemes', async () => {
|
||||
const actual = await vi.importActual('#style/customThemes');
|
||||
@@ -59,10 +64,11 @@ describe('ThemeInstaller', () => {
|
||||
--color-secondary: #6c757d;
|
||||
}`;
|
||||
|
||||
const mockCatalog = [
|
||||
const mockCatalog: CatalogTheme[] = [
|
||||
{
|
||||
name: 'Demo Theme',
|
||||
repo: 'actualbudget/demo-theme',
|
||||
mode: 'dark',
|
||||
colors: [
|
||||
'#1a1a2e',
|
||||
'#16213e',
|
||||
@@ -75,6 +81,7 @@ describe('ThemeInstaller', () => {
|
||||
{
|
||||
name: 'Ocean Blue',
|
||||
repo: 'actualbudget/ocean-theme',
|
||||
mode: 'light',
|
||||
colors: [
|
||||
'#0d47a1',
|
||||
'#1565c0',
|
||||
@@ -87,6 +94,7 @@ describe('ThemeInstaller', () => {
|
||||
{
|
||||
name: 'Forest Green',
|
||||
repo: 'actualbudget/forest-theme',
|
||||
mode: 'light',
|
||||
colors: [
|
||||
'#1b5e20',
|
||||
'#2e7d32',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import type { GoCardlessToken } from '@actual-app/core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
GoCardlessToken,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
import { pushModal } from './modals/modalsSlice';
|
||||
import type { AppDispatch } from './redux/store';
|
||||
@@ -41,7 +44,10 @@ function _authorize(
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorizeBank(dispatch: AppDispatch) {
|
||||
export async function authorizeBank(
|
||||
dispatch: AppDispatch,
|
||||
upgradingAccountId?: AccountEntity['id'],
|
||||
) {
|
||||
_authorize(dispatch, {
|
||||
onSuccess: async data => {
|
||||
dispatch(
|
||||
@@ -52,6 +58,7 @@ export async function authorizeBank(dispatch: AppDispatch) {
|
||||
externalAccounts: data.accounts,
|
||||
requisitionId: data.id,
|
||||
syncSource: 'goCardless',
|
||||
upgradingAccountId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
|
||||
@@ -610,6 +610,7 @@ export type Modal =
|
||||
name: 'category-automations-edit';
|
||||
options: {
|
||||
categoryId: CategoryEntity['id'];
|
||||
month?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||