Compare commits

...

22 Commits

Author SHA1 Message Date
77590b4b4a Reverse transactions display order 2025-10-22 12:19:47 +02:00
1f164555be Upgrade 2025-09-28 21:23:01 +02:00
32b8d50e5b Add support for fetching only desired transactions related to specified profile 2025-07-08 12:58:11 +02:00
9b24a1d737 Upgrade 2025-07-08 12:12:18 +02:00
8a6dd58007 Enable existing transaction fetching debouncing 2025-05-24 22:27:41 +02:00
1b6749ef53 Fix importing transactions in wrong order 2025-05-23 13:42:26 +02:00
cc208dd748 Add support for existing transactions 2025-05-22 09:45:29 +02:00
e1dc42c254 Replace transactions table with notifications 2025-05-21 14:12:36 +02:00
490d6aa650 Improve appearance of transactions table 2025-05-21 13:47:10 +02:00
9d2ac48e1a Add support for starting over on second step 2025-05-20 16:42:56 +02:00
a75e6232be Change default submit mode to 'add' 2025-05-20 16:34:05 +02:00
af15a352a3 Add support for filtering dates 2025-05-20 16:27:31 +02:00
e3618c539e Add number of selected transactions 2025-05-09 20:04:31 +02:00
9df89d0ffc Add button 'Start over again' 2025-05-09 19:46:49 +02:00
4959cf1085 Disable button when no data selected 2025-05-09 19:37:46 +02:00
f9cfdbd4a0 Fix requests base URL 2025-05-09 19:37:28 +02:00
3a5ff132ed Update packages 2025-05-09 17:12:10 +02:00
90b24ec865 Create loader 2025-05-09 17:07:48 +02:00
6a557cc060 Add support for PWA 2025-05-09 14:47:30 +02:00
80977ee896 Add support for frontend app in express server 2025-05-09 14:33:16 +02:00
192f21c3a6 Create working PoC of frontend app 2025-05-09 14:32:05 +02:00
149d8f01b7 Implement store 2025-05-09 11:17:58 +02:00
37 changed files with 5663 additions and 1819 deletions

1
.gitignore vendored
View File

@@ -159,6 +159,7 @@ web/node_modules
web/dist
web/dist-ssr
web/*.local
web/dev-dist
# Editor directories and files
.vscode/*

847
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,12 +27,12 @@
"@types/express": "^5.0.1",
"@types/multer": "^1.4.12",
"@types/papaparse": "^5.3.15",
"classnames": "^2.5.1",
"commander": "^13.1.0",
"express": "^5.1.0",
"iconv-lite": "^0.6.3",
"multer": "^1.4.5-lts.2",
"papaparse": "^5.5.2",
"pug": "^3.0.3",
"yaml": "^2.7.1"
}
}

View File

@@ -1,16 +1,19 @@
{
pkgs,
buildNpmPackage,
lib,
...
}:
buildNpmPackage {
pname = "actual-importer";
version = "0.0.1";
src = ./.;
npmDepsHash = "sha256-QqSZJuQLPll3qFi5Lv4kbQo+548l3VKgx073WLNXMsw=";
}: let
frontend = pkgs.callPackage ./web/package.nix {};
in
buildNpmPackage {
pname = "actual-importer";
version = "0.0.1";
src = ./.;
npmDepsHash = "sha256-SCXZr/Lpyzx8RoUnaM9hsBe5bLJ2xxRzruQv/2Lbetg=";
postInstall = ''
mkdir -p $out/views
cp -r ${./views}/* $out/views/
'';
}
postInstall = ''
mkdir -p $out/lib/node_modules/actual-importer/public
cp -r ${frontend}/lib/node_modules/web/dist/* $out/lib/node_modules/actual-importer/public/
'';
}

View File

@@ -14,9 +14,9 @@ export type SubmitOptions =
type ActualTransaction = {
id?: string;
account?: string;
account: string;
date: string;
amount?: number;
amount: number;
payee?: string;
payee_name?: string;
imported_payee?: string;
@@ -51,7 +51,7 @@ export class Actual {
}
#map(transaction: Transaction, accounts: ActualAccount[], payees: ActualPayee[]): ActualTransaction {
const actualTransaction: ActualTransaction = {
const actualTransaction: Omit<ActualTransaction, 'account'> & { account?: string } = {
imported_id: transaction.id,
date: transaction.date,
amount: utils.amountToInteger(transaction.amount),
@@ -94,7 +94,7 @@ export class Actual {
break;
}
return actualTransaction;
return actualTransaction as ActualTransaction;
}
async #api<T>(fn: () => Promise<T>): Promise<T> {
@@ -186,6 +186,74 @@ export class Actual {
return this.#doSubmit(prepared, opts);
});
}
async getTransactions(limit: number): Promise<Transaction[]> {
return this.#api(async () => {
const accounts: ActualAccount[] = await api.getAccounts();
const payees: ActualPayee[] = await api.getPayees();
const query = api.q('transactions')
.limit(limit)
.select(['*'])
.options({ splits: 'grouped' });
const { data } = await api.runQuery(query) as { data: ActualTransaction[] };
return this.#mapActualTransactionsToTransactions(data, accounts, payees);
});
}
async getTransactionsFromRange(start: string, end: string): Promise<Transaction[]> {
return this.#api(async () => {
const accounts: ActualAccount[] = await api.getAccounts();
const payees: ActualPayee[] = await api.getPayees();
const query = api.q('transactions')
.filter({ date: [{ $gte: start }, { $lte: end }] })
.select(['*'])
.options({ splits: 'grouped' });
const { data } = await api.runQuery(query) as { data: ActualTransaction[] };
return this.#mapActualTransactionsToTransactions(data, accounts, payees);
});
}
#mapActualTransactionsToTransactions(transactions: ActualTransaction[], accounts: ActualAccount[], payees: ActualPayee[]): Transaction[] {
const transfers: string[] = [];
return transactions.map(t => {
const account = accounts.find(a => a.id === t.account)?.name ?? t.account ?? "--unknown--";
const payee = payees.find(p => p.id === t.payee)?.name ?? t.payee ?? "--unknown--";
let from = account;
let to = payee;
if (t.amount && t.amount > 0) {
from = payee;
to = account;
}
if (t.transfer_id) {
if (transfers.includes(t.id!!)) {
return undefined;
}
transfers.push(t.transfer_id);
}
return {
kind: t.transfer_id ? 'transfer' : 'regular',
date: t.date,
from,
to,
fromDetails: from,
toDetails: to,
amount: (t.amount ?? 0)/100,
title: t.notes
} as Transaction;
}).filter(t => t !== undefined);
}
}
async function sleep(ms: number): Promise<void> {

View File

@@ -15,6 +15,29 @@ export type ImportResult = PrepareResult & {
result: ActualImportResult;
};
export const getTransactions = async (server: string, config: Config, limit: number = 5): Promise<Transaction[]> => {
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const actualServer = new Actual(serverConfig);
return await actualServer.getTransactions(limit);
};
export const getTransactionsFromRange = async (server: string, config: Config, start: string, end: string): Promise<Transaction[]> => {
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const actualServer = new Actual(serverConfig);
return await actualServer.getTransactionsFromRange(start, end);
};
export const prepareTransactions = async (stream: Readable, profile: string, server: string, config: Config): Promise<PrepareResult> => new Promise((resolve, reject) => {
const profileConfig = config.profiles[profile];

View File

@@ -1,35 +1,17 @@
import { prepareTransactions, submitTransactions } from "@/runner";
import { getTransactions, getTransactionsFromRange, prepareTransactions, submitTransactions } from "@/runner";
import { Config } from "@/types/config";
import express from "express";
import multer from "multer";
import { Readable } from "stream";
import path from 'path';
import { Transaction } from "@/types/transaction";
export function serve(config: Config, port: number) {
const app = express();
app.set('view engine', 'pug');
app.set('views', path.join(__dirname, '../../views'));
app.use(express.static('public'));
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
const upload = multer({ storage: multer.memoryStorage() });
app.get("/", (req, res) => {
res.render('index', {
profiles: Object.keys(config.profiles),
servers: Object.keys(config.servers),
defaultProfile: config.defaultProfile,
defaultServer: config.defaultServer
});
});
app.get("/config", (req, res) => {
res.json({
profiles: Object.keys(config.profiles),
@@ -39,6 +21,27 @@ export function serve(config: Config, port: number) {
});
});
app.get("/servers/:server/transactions", async (req, res) => {
const { start, end } = req.query;
let data: Transaction[]|undefined;
if (start && end) {
data = await getTransactionsFromRange(req.params.server, config, start.toString(), end.toString());
}
else {
const limitParam = req.query.limit?.toString();
const limit = (limitParam && Number.parseInt(limitParam)) || undefined;
data = await getTransactions(req.params.server, config, limit);
}
const profile = req.query.profile?.toString() || undefined;
const supportedAccounts = profile !== undefined ? config.profiles[profile].supportedAccounts : undefined;
const filterAccounts = supportedAccounts !== undefined ? (t: Transaction) => supportedAccounts.includes(t.from) || supportedAccounts.includes(t.to) : () => true;
res.json(data.filter(filterAccounts))
});
app.post("/prepare", upload.single("file"), async (req, res) => {
if (!req.file) {
throw new Error("No file to upload");
@@ -61,7 +64,12 @@ export function serve(config: Config, port: number) {
res.send(response);
});
app.use(express.static(path.join(__dirname, '../../public')));
app.get('*public', (req, res) => {
res.sendFile(path.join(__dirname, '../../', 'public', 'index.html'));
});
app.listen(port, () => {
console.log(`Server running on ${port} port`);
});

View File

@@ -11,6 +11,7 @@ export type ProfileConfig = {
encoding?: string;
config?: ParserConfig;
csv?: Record<string, unknown>;
supportedAccounts?: string[];
};
export type ParserConfig = {

View File

@@ -1,222 +0,0 @@
html
head
title Actual Importer
body
h1 Import transactions
form#prepareForm
p
label(for="file") CSV File:
input(type="file" name="file" required)
p
label(for="profile") Profile:
select(name="profile" required)
each profile in profiles
option(value=profile selected=(defaultProfile === profile))= profile
p
label(for="server") Server:
select(name="server" required)
each server in servers
option(value=server selected=(defaultServer === server))= server
button(type="submit") Load
div#prepare
div#result
script.
const form = document.querySelector("#prepareForm");
function renderTable(transactions) {
const div = document.createElement('div');
div.innerHTML = `
<h3>Pending transactions (${transactions.length})</h3>
`;
const table = document.createElement('table');
const thead = document.createElement('thead');
const tr = document.createElement('tr');
tr.innerHTML = `
<th>#</th>
<th>Import</th>
<th>ID</th>
<th>Kind</th>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Amount</th>
<th>Title</th>
`;
thead.appendChild(tr);
table.appendChild(thead);
const tbody = document.createElement('tbody');
transactions.forEach((transaction, index) => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${index+1}</td>
<td><input type="checkbox" id="transaction${index}" checked/></td>
<td>${transaction.id}</td>
<td>${transaction.kind}</td>
<td>${transaction.date}</td>
<td>${transaction.from}</td>
<td>${transaction.to}</td>
<td>${transaction.amount}</td>
<td>${transaction.title}</td>
`;
tbody.appendChild(row);
});
table.appendChild(tbody);
div.appendChild(table);
return div;
}
function renderSkipped(skipped) {
const div = document.createElement('div');
div.innerHTML = `
<h3>Skipped CSV rows</h3>
<p>The <tt>:::</tt> is CSV field delimiter</p>
<pre>${skipped.map(d => d.join(" ::: ")).join("\n")}</pre>
`
return div;
}
function renderResult(result) {
const resultWrapper = document.querySelector('#result');
resultWrapper.innerHTML = `
<h2>Import status</h3>
<p>
<ul>
<li>Added: <strong>${result.added.length}</strong></li>
<li>Updated: <strong>${result.updated.length}</strong></li>
<li>Errors: <strong>${result?.errors?.length ?? 0}</strong></li>
</ul>
${(result.errors?.length ?? 0) > 0
? `<p>Errors: <ul>${result.errors?.map(e => `<li>${e.message}</li>`)}</ul></p>`
: ""}
</p>
`;
}
async function submitTransactions(config, transactions, opts) {
const filtered = transactions.filter((transaction, index) => !!document.querySelector(`#transaction${index}`).checked);
if (filtered.length === 0) {
return;
}
try {
const response = await fetch("/submit", {
method: "POST",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
server: config.get('server'),
transactions: filtered,
opts
}),
});
const result = await response.json();
renderResult(result);
} catch (e) {
console.error(e);
}
}
function renderPrepare(config, data) {
document.querySelector('#result').innerHTML = '';
const prepare = document.querySelector("#prepare");
prepare.innerHTML = '';
const title = document.createElement("h2");
title.innerHTML = "Prepare transactions to submit";
prepare.appendChild(title);
prepare.appendChild(renderTable(data.transactions));
prepare.appendChild(renderSkipped(data.skipped));
const addForm = document.createElement("form");
addForm.innerHTML = `
<fieldset>
<legend>Add</legend>
<p>
<label for="learn">Learn categories</label>
<input type="checkbox" name="learn" />
</p>
<p>
<label for="transfers">Run transfers</label>
<input type="checkbox" name="transfers" />
</p>
<button type="submit">Add</button>
</fieldset>
`;
addForm.addEventListener("submit", async (e) => {
e.preventDefault();
const options = new FormData(addForm);
const opts = {
mode: 'add',
learnCategories: !!options.get('learn'),
runTransfers: !!options.get('transfers')
};
await submitTransactions(config, data.transactions, opts);
prepare.innerHTML = '';
});
const importForm = document.createElement("form");
importForm.innerHTML = `
<fieldset>
<legend>Import</legend>
<button type="submit">Import</button>
</fieldset>
`;
importForm.addEventListener("submit", async (e) => {
e.preventDefault();
await submitTransactions(config, data.transactions, { mode: 'import' });
prepare.innerHTML = '';
});
if (data.transactions.length > 0) {
prepare.appendChild(addForm);
prepare.appendChild(importForm);
} else {
const message = document.createElement('p');
message.innerHTML = `
<strong>No transactions to import</strong>
`;
prepare.appendChild(message);
}
}
async function handlePrepare(e) {
e.preventDefault();
try {
const config = new FormData(form);
const response = await fetch("/prepare", {
method: "POST",
body: config,
});
const data = await response.json();
renderPrepare(config, data);
} catch (e) {
console.error(e);
}
}
form.addEventListener("submit", handlePrepare);

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Actual Importer</title>
</head>
<body>
<div id="root"></div>

5235
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,13 @@
},
"dependencies": {
"bulma": "^1.0.4",
"dayjs": "^1.11.13",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/classnames": "^2.3.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
@@ -25,6 +27,7 @@
"globals": "^16.0.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.0"
}
}

View File

@@ -7,5 +7,5 @@ buildNpmPackage {
pname = "actual-importer-frontend";
version = "0.0.1";
src = ./.;
npmDepsHash = "sha256-CC9mDUyLBPigwlccQMauNoqzHKMeOppPu5y2eJXIzLY=";
npmDepsHash = "sha256-Xh+zpYX8u+wKuvBjGc4hxUI2Ed4tiXKC3zk8kFExBbc=";
}

View File

@@ -1,6 +1,18 @@
import LoadFilePage from "./pages/LoadFilePage/LoadFilePage";
import PrepareTransactionsPage from "./pages/PrepareTransactionsPage/PrepareTransactionsPage";
import ResultPage from "./pages/ResultPage/ResultPage";
import { AppStoreProvider } from "./store/AppStore";
import { Wizard } from "./wizard/Wizard";
export default function App() {
return (<LoadFilePage />);
return (
<AppStoreProvider>
<Wizard>
<LoadFilePage />
<PrepareTransactionsPage />
<ResultPage />
</Wizard>
</AppStoreProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from "react";
export type UseLoaderReturnType<T extends any[], R> = {
fn: (...args: T) => Promise<R>,
loading: boolean;
completed: boolean;
error?: unknown;
};
export function useLoader<T extends any[], R>(fn: (...args: T) => Promise<R>, deps: React.DependencyList = []) {
const [loading, setLoading] = useState(false);
const [completed, setCompleted] = useState(false);
const [error, setError] = useState<unknown|undefined>(undefined);
const callback = useCallback(async (...args: T): Promise<R> => {
setLoading(true);
setCompleted(false);
setError(undefined);
// Wait 1 tick
await new Promise(resolve => setTimeout(resolve, 0));
try {
const value = await fn(...args);
setCompleted(true);
return value;
} catch(e: unknown) {
setError(e);
throw e;
} finally {
setLoading(false);
}
}, [fn, ...deps]);
return {
fn: callback,
loading,
completed,
error
};
}

View File

@@ -9,3 +9,9 @@ createRoot(document.getElementById('root')!).render(
<App />
</StrictMode>,
)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
})
}

View File

@@ -1,8 +1,10 @@
import classNames from "classnames";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export type LoadFileFormProps = {
profiles: string[];
servers: string[];
loading: boolean;
defaultProfile?: string;
defaultServer?: string;
onSubmit?: (csvFile: File, profile: string, server: string) => Promise<void>;
@@ -14,7 +16,7 @@ type Form = {
server?: string;
};
export default function LoadFileForm({ profiles, servers, defaultProfile, defaultServer, onSubmit }: LoadFileFormProps) {
export default function LoadFileForm({ profiles, servers, defaultProfile, defaultServer, onSubmit, loading }: LoadFileFormProps) {
const fileInput = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<Form>({});
@@ -36,22 +38,24 @@ export default function LoadFileForm({ profiles, servers, defaultProfile, defaul
return undefined;
}, [formData.files]);
const isValid = useMemo(() => selectedFile && formData.profile && formData.server, [formData, selectedFile]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile || !formData.profile || !formData.server) {
return;
}
onSubmit?.(selectedFile, formData.profile, formData.server);
}, [formData, formData, selectedFile, onSubmit]);
}, [formData, selectedFile, onSubmit]);
return (
<form onSubmit={handleSubmit}>
<div className="field">
<label className="label">CSV File</label>
<div className="control">
<div className={`file is-fullwidth ${!!selectedFile ? "has-name" : ""}`}>
<div className="control">
<div className={classNames("file", "is-fullwidth", { "has-name": selectedFile })}>
<label className="file-label">
<input className="file-input" ref={fileInput} type="file" accept=".csv" onChange={(e) => setFormData({...formData, files: e.target.files ?? undefined})} required />
<span className="file-cta">
@@ -95,9 +99,8 @@ export default function LoadFileForm({ profiles, servers, defaultProfile, defaul
<div className="control">
<button
type="submit"
className="button is-link is-fullwidth"
>
Load transactions
className={classNames("button", "is-link", "is-fullwidth", { 'is-loading': loading })}
disabled={!isValid || loading}>Load transactions
</button>
</div>
</div>

View File

@@ -1,8 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import LoadFileForm from './LoadFileForm';
import { fetchConfig, loadTransactions } from '../../services/api.service';
import { useStore } from '../../store/AppStore';
import { useLoader } from '../../hooks/useLoader';
export default function LoadFilePage() {
const { dispatch } = useStore();
const [profiles, setProfiles] = useState<string[]>([]);
const [servers, setServers] = useState<string[]>([]);
@@ -16,23 +19,44 @@ export default function LoadFilePage() {
setServers(config.servers);
setDefaultProfile(config.defaultProfile);
setDefaultServer(config.defaultServer);
dispatch({
type: 'UPDATE_CONFIG',
payload: config
});
}, [setProfiles, setServers, setDefaultProfile, setDefaultServer]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
}, [loadConfig]);
const handleSubmit = useCallback(async (csvFile: File, profile: string, server: string) => {
const { fn: handleSubmit, loading } = useLoader(async (csvFile: File, profile: string, server: string) => {
const data = await loadTransactions(csvFile, profile, server);
console.log(data);
}, []);
dispatch({
type: 'UPDATE_DATA',
payload: {
profile,
server,
...data
}
});
dispatch({
type: 'MOVE_STEP',
payload: {
offset: 1
}
});
});
return (
<div className="columns mt-6">
<div className="column is-6 is-offset-3">
<div className="box">
<h2 className="title is-4 has-text-centered">Import Transactions</h2>
<h2 className="title is-4 has-text-centered">Import Transactions</h2>
<LoadFileForm
loading={loading}
profiles={profiles}
servers={servers}
defaultProfile={defaultProfile}

View File

@@ -0,0 +1,54 @@
import classNames from "classnames";
import type { Transaction } from "../../types/api";
import { useCallback } from "react";
export type ExistingTransactionsProps = {
transactions: Transaction[];
loading: boolean;
};
const transactionEmoji = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return '➡️'
};
return transaction.amount > 0 ? '⬇️' : '⬆️';
};
const transactionType = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return 'Transfer'
};
return transaction.amount > 0 ? 'Inflow' : 'Outflow';
};
export function ExistingTransactions({ transactions, loading }: ExistingTransactionsProps) {
const renderRow = useCallback((transaction: Transaction, index: number) => (
<div
key={index}
className={classNames('notification', 'is-light', {
'is-success': transaction.kind === 'regular' && transaction.amount > 0,
'is-danger': transaction.kind === 'regular' && transaction.amount < 0,
'is-info': transaction.kind === 'transfer',
})}>
<h6 className="title is-6" title={transactionType(transaction)}>
{transactionEmoji(transaction)} {transaction.date}
</h6>
<strong>From:</strong> {transaction.from}<br />
<strong>To:</strong> {transaction.to}<br />
<strong>Title:</strong> {transaction.title}<br />
<strong>Amount:</strong> {transaction.amount}
</div>
), []);
return (
<>
{loading
? <div className="notification is-warning is-light">
<strong>Loading...</strong>
</div>
: transactions.map(renderRow).toReversed()}
</>
);
}

View File

@@ -0,0 +1,121 @@
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { useCallback } from "react";
export type FilterData = {
from?: Dayjs;
to?: Dayjs;
};
export type FilterPanelProps = {
value?: FilterData;
setValue: (value: FilterData) => void;
};
const str2dayjs = (text?: string) => text ? dayjs(text) : undefined;
const dayjs2str = (date?: Dayjs) => !date ? "" : date.format("YYYY-MM-DD");
export function FilterPanel({ value, setValue }: FilterPanelProps) {
const handleChange = useCallback(<K extends keyof FilterData>(k: K, v: FilterData[K]) => {
setValue({ ...value, [k]: v });
}, [value]);
const handleQuickDate = useCallback((key: 'from'|'to', days: number) => {
const date = dayjs().subtract(days, 'days');
setValue({
...value,
[key]: date
});
}, [value, setValue]);
return (<>
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Date range</label>
</div>
<div className="field-body">
<div className="field">
<div className="control is-expanded">
<input
className="input"
type="date"
value={dayjs2str(value?.from)}
onChange={e => handleChange('from', str2dayjs(e.target.value))}
/>
<p className="help"><strong>From</strong> (transactions not earlier than...)</p>
<div className="field is-grouped">
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 0)}>
Today
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 1)}>
Yesterday
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 2)}>
-2d
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('from', 3)}>
-3d
</button>
</div>
</div>
</div>
</div>
<div className="field">
<div className="control is-expanded">
<input
className="input"
type="date"
value={dayjs2str(value?.to)}
onChange={e => handleChange('to', str2dayjs(e.target.value))}
/>
<p className="help"><strong>To</strong> (transactions not later than...)</p>
<div className="field is-grouped">
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 0)}>
Today
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 1)}>
Yesterday
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 2)}>
-2d
</button>
</div>
<div className="control">
<button className="button is-dark is-outlined is-small" onClick={() => handleQuickDate('to', 3)}>
-3d
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label"/>
</div>
<div className="field-body">
<div className="field">
<div className="control">
<button className="button is-danger is-outlined" onClick={() => setValue({})}>
Reset filters
</button>
</div>
</div>
</div>
</div>
</>);
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useState } from "react";
import type { ImportOptions } from "../../types/api";
import classNames from "classnames";
export type ImportBarProps = {
onSubmit: (opts: ImportOptions) => void;
onStartOver: () => void;
loading: boolean;
};
export default function ImportBar({ onSubmit, loading, onStartOver }: ImportBarProps) {
const [formData, setFormData] = useState<ImportOptions>({ mode: 'add' });
const handleSubmit = useCallback((e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
}, [formData, onSubmit]);
const changeMode = useCallback((mode: ImportOptions['mode']) => {
if (mode === 'add') {
setFormData({ ...formData, mode });
}
else {
setFormData({ mode });
}
}, [formData, setFormData]);
return (
<form onSubmit={handleSubmit}>
<div className="field">
<label className="label">Import mode</label>
<div className="control">
<div className="buttons has-addons is-left">
<button
type="button"
className={classNames("button", { 'is-primary': formData.mode === 'add', 'is-selected': formData.mode === 'add'})}
onClick={() => changeMode('add')}>Add</button>
<button
type="button"
className={classNames("button", { 'is-primary': formData.mode === 'import', 'is-selected': formData.mode === 'import'})}
onClick={() => changeMode('import')}>Import</button>
</div>
</div>
</div>
{formData.mode === 'add' && <div className="field">
<label className="label">Additional options</label>
<div className="control">
<label className="checkbox">
<input
type="checkbox"
checked={formData.learnCategories}
onChange={e => setFormData({ ...formData, learnCategories: e.target.checked})}/> Learn categories
</label>
</div>
<div className="control">
<label className="checkbox">
<input
type="checkbox"
checked={formData.runTransfers}
onChange={e => setFormData({ ...formData, runTransfers: e.target.checked})}/> Run transfers
</label>
</div>
</div>}
<div className="field mt-5">
<div className="control">
<button
type="submit"
disabled={loading}
className={classNames("button", "is-link", "is-fullwidth", { 'is-loading': loading })}>Submit transactions
</button>
</div>
</div>
<div className="field">
<div className="control">
<button
type="button"
className="button is-secondary is-fullwidth"
onClick={onStartOver}>Start over again
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,167 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useStore } from "../../store/AppStore";
import TransactionsTable from "./TransactionsTable";
import { SkippedLines } from "./SkippedLines";
import ImportBar from "./ImportBar";
import { type ImportOptions, type Transaction } from "../../types/api";
import { getDateRangedTransactions, getLatestTransactions, submitTransactions } from "../../services/api.service";
import { useLoader } from "../../hooks/useLoader";
import { FilterPanel, type FilterData } from "./FilterPanel";
import dayjs from "dayjs";
import { ExistingTransactions } from "./ExistingTransactions";
import classNames from "classnames";
import { useDebounce } from "../../hooks/useDebounce";
export default function PrepareTransactionsPage() {
const { state, dispatch } = useStore();
const [userFilter, setUserFilter] = useState<FilterData>({});
const debouncedUserFilter = useDebounce(userFilter, 700);
const [deselected, setDeselected] = useState<number[]>([]);
const [enabledExistingTransactions, setEnabledExistingTransactions] = useState(true);
const [existingTransactions, setExistingTransactions] = useState<Transaction[]>([]);
const select = useCallback((index: number) => setDeselected(deselected.filter(x => x !== index)), [deselected, setDeselected]);
const deselect = useCallback((index: number) => setDeselected([...deselected, index]), [deselected, setDeselected]);
const filteredTransactions = useMemo(() => state.transactions
.filter(t => {
if (!userFilter.from) {
return true;
}
const date = dayjs(t.date);
return date.isAfter(userFilter.from, 'day') || date.isSame(userFilter.from, 'day');
})
.filter(t => {
if (!userFilter.to) {
return true;
}
const date = dayjs(t.date);
return date.isBefore(userFilter.to, 'day') || date.isSame(userFilter.to, 'day');
})
.slice().reverse()
, [state.transactions, userFilter]);
const selectedTransactions = useMemo(() =>
filteredTransactions.filter((_, i) => !deselected.includes(i)),
[filteredTransactions, deselected]);
const { fn: handleSubmit, loading } = useLoader(async (opts: ImportOptions) => {
if(!state.server) {
return;
}
const response = await submitTransactions(selectedTransactions.slice().reverse(), state.server, opts);
dispatch({
type: 'SET_RESULT',
payload: response
});
dispatch({
type: 'MOVE_STEP',
payload: {
offset: 1
}
});
}, [selectedTransactions, state]);
const { fn: refreshExistingTransactions, loading: loadingExistingTransactions } = useLoader(async () => {
if (!state.server || !enabledExistingTransactions) {
setExistingTransactions([]);
return;
}
// If no "from" filter active, pull the latest 10 transactions
if (!debouncedUserFilter.from) {
const transactions = await getLatestTransactions(state.server, 10, state.profile);
setExistingTransactions(transactions);
return;
}
const start = debouncedUserFilter.from.format("YYYY-MM-DD");
const end = (debouncedUserFilter.to ?? dayjs()).format("YYYY-MM-DD");
const transactions = await getDateRangedTransactions(state.server, start, end, state.profile);
setExistingTransactions(transactions);
}, [enabledExistingTransactions, state.server, debouncedUserFilter]);
useEffect(() => {
setDeselected([])
}, [filteredTransactions]);
useEffect(() => {
refreshExistingTransactions()
}, [enabledExistingTransactions, state.server, debouncedUserFilter]);
return (
<div className="columns mt-6">
<div className="column is-8 is-offset-2">
<div className="box">
<h2 className="title is-4 has-text-centered">Prepare Transactions</h2>
<div className="content">
<h3 className="title is-5">Filters</h3>
<FilterPanel value={userFilter} setValue={setUserFilter} />
</div>
<div className="field">
<label className="label">Fetch existing transactions</label>
<div className="notification is-info is-light">
If enabled, the existing transactions will be fetched automatically basing on date filter.
If "from" is filled, the existing transactions will be fetched basing on set date (if "to" is not filled, the todays date will be considered).
Otherwise, the latest <abbr title="Transaction before transfer consolidation.">10 raw transactions</abbr> will be fetched. Because of transfer consolidation,
the ultimate number of transactions can be lower.
</div>
<div className="control">
<div className="buttons has-addons">
<button
className={classNames('button', { 'is-selected': enabledExistingTransactions, 'is-success': enabledExistingTransactions})}
onClick={() => setEnabledExistingTransactions(true)}
disabled={enabledExistingTransactions}>I</button>
<button
className={classNames('button', { 'is-selected': !enabledExistingTransactions, 'is-danger': !enabledExistingTransactions})}
onClick={() => setEnabledExistingTransactions(false)}
disabled={!enabledExistingTransactions}>O</button>
</div>
</div>
</div>
<div className="columns">
<div className={classNames('column', { 'is-6': enabledExistingTransactions, 'is-12': !enabledExistingTransactions })}>
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
<TransactionsTable
transactions={filteredTransactions}
deselected={deselected}
deselect={deselect}
select={select} />
</div>
{enabledExistingTransactions &&
<div className="column is-6">
<h3 className="title is-5">Existing transactions ({existingTransactions.length})</h3>
<ExistingTransactions
loading={loadingExistingTransactions}
transactions={existingTransactions} />
</div>}
</div>
<div className="content">
<h3 className="title is-5">Skipped lines ({state.skipped.length})</h3>
<SkippedLines skipped={state.skipped} />
</div>
<div className="content">
<ImportBar loading={loading} onSubmit={handleSubmit} onStartOver={() => location.reload()}/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
.skipped {
content: red;
}

View File

@@ -0,0 +1,19 @@
import styles from "./SkippedLines.module.css";
export type SkippedLinesProps = {
skipped: string[][];
};
export function SkippedLines({ skipped }: SkippedLinesProps) {
if (skipped.length === 0) {
return undefined;
}
return (
<div className={styles.skipped}>
<pre>
{skipped.map(s => `▶️ ${s.join("🔸")}`).join("\n")}
</pre>
</div>
);
}

View File

@@ -0,0 +1,5 @@
.transactionsTable {
.transaction {
cursor: pointer;
}
}

View File

@@ -0,0 +1,72 @@
import { useCallback } from "react";
import type { Transaction } from "../../types/api";
import styles from "./TransactionsTable.module.css";
import classNames from "classnames";
export type TransactionsTableProps = {
transactions: Transaction[];
deselected: number[];
deselect: (index: number) => void;
select: (index: number) => void;
readonly?: boolean;
}
const transactionEmoji = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return '➡️'
};
return transaction.amount > 0 ? '⬇️' : '⬆️';
};
const transactionType = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return 'Transfer'
};
return transaction.amount > 0 ? 'Inflow' : 'Outflow';
};
export default function TransactionsTable({ transactions, deselected, deselect, select, readonly }: TransactionsTableProps) {
const changeSelection = useCallback((index: number) => {
if (readonly) {
return;
}
if(deselected.includes(index)) {
select(index);
}
else {
deselect(index);
}
}, [deselected, deselect, select]);
const renderRow = useCallback((transaction: Transaction, index: number) => (
<div
key={index}
onClick={() => changeSelection(index)}
className={classNames('notification', styles.transaction, {
'is-success': transaction.kind === 'regular' && transaction.amount > 0,
'is-danger': transaction.kind === 'regular' && transaction.amount < 0,
'is-info': transaction.kind === 'transfer',
'is-light': deselected.includes(index)
})}>
<h6 className="title is-6" title={transactionType(transaction)}>
{transactionEmoji(transaction)} {transaction.date}
</h6>
{transaction.id && (<><strong>ID:</strong> {transaction.id}<br /></>)}
<strong>From:</strong> {transaction.from}<br />
<strong>To:</strong> {transaction.to}<br />
<strong>Title:</strong> {transaction.title}<br />
<strong>Amount:</strong> <span className={styles.amount}>{transaction.amount}</span>
</div>
), [changeSelection]);
return (
<div className={styles.transactionsTable}>
{transactions.map(renderRow).toReversed()}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { useStore } from "../../store/AppStore";
export default function ResultPage() {
const { state } = useStore();
const errors = useMemo(() => {
if (!state.result?.errors) {
return undefined;
}
if (state.result.errors.length === 0) {
return undefined;
}
return (
<div className="notification is-danger">
<p>Errors:</p>
<ul>
{state.result.errors.map(e => <li>{e.message}</li>)}
</ul>
</div>
)
}, [state.result?.errors]);
return (
<div className="columns mt-6">
<div className="column is-6 is-offset-3">
<div className="box">
<h2 className="title is-4 has-text-centered">Import Result</h2>
<div className="content">
<div className="notification is-info">
<ul>
<li>Added: <strong>{state.result?.added?.length ?? 0}</strong></li>
<li>Updated: <strong>{state.result?.updated?.length ?? 0}</strong></li>
<li>Errors: <strong>{state.result?.errors?.length ?? 0}</strong></li>
</ul>
</div>
</div>
<div className="content">
{errors}
</div>
<button
type="button"
className="button is-secondary is-fullwidth"
onClick={() => location.reload()}>Start over again
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import type { ConfigResponse, PrepareResponse } from "../types/api";
import type { ConfigResponse, ImportOptions, PrepareResponse, SubmitResponse, Transaction } from "../types/api";
export async function fetchConfig(): Promise<ConfigResponse> {
const response = await fetch("http://localhost:3000/config")
const response = await fetch("/config")
const data = await response.json();
return data as ConfigResponse;
}
@@ -12,11 +12,58 @@ export async function loadTransactions(csvFile: File, profile: string, server: s
payload.append("profile", profile);
payload.append("server", server);
const response = await fetch("http://localhost:3000/prepare", {
const response = await fetch("/prepare", {
method: "POST",
body: payload
});
const data = await response.json();
return data as PrepareResponse;
}
export async function submitTransactions(transactions: Transaction[], server: string, opts: ImportOptions): Promise<SubmitResponse> {
const payload = {
transactions,
server,
opts
};
const response = await fetch("/submit", {
method: "POST",
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
return data as SubmitResponse;
}
export async function getLatestTransactions(server: string, limit: number = 5, profile?: string): Promise<Transaction[]> {
const params = new URLSearchParams();
params.append('limit', limit.toString());
if (profile !== undefined) {
params.append('profile', profile);
}
const response = await fetch(`/servers/${server}/transactions?${params.toString()}`);
const data = await response.json();
return data as Transaction[];
}
export async function getDateRangedTransactions(server: string, start: string, end: string, profile?: string): Promise<Transaction[]> {
const params = new URLSearchParams();
params.append('start', start);
params.append('end', end);
if (profile !== undefined) {
params.append('profile', profile);
}
const response = await fetch(`/servers/${server}/transactions?${params.toString()}`);
const data = await response.json();
return data as Transaction[];
}

View File

@@ -0,0 +1,38 @@
import type { StoreContext } from "../types/store";
import { createContext, useContext, useMemo, useReducer } from "react";
import { initialState } from "./state";
import { reducer } from "./reducer";
export type AppStoreProviderProps = {
children: React.ReactNode;
}
export type UseStoreReturnType = StoreContext & {
};
const AppContext = createContext<StoreContext>({
state: initialState,
dispatch: () => {}
});
export function AppStoreProvider({ children }: AppStoreProviderProps) {
const [state, dispatch] = useReducer(reducer, initialState);
const context = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return (
<AppContext.Provider value={context}>
{children}
</AppContext.Provider>
);
}
export function useStore(): UseStoreReturnType {
const { state, dispatch } = useContext(AppContext);
return {
state,
dispatch,
}
}

47
web/src/store/reducer.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Action, State } from "../types/store";
export function reducer(state: State, action: Action): State {
switch(action.type) {
case 'UPDATE_CONFIG':
return {
...state,
availableProfiles: action.payload.profiles,
availableServers: action.payload.servers,
defaultProfile: action.payload.defaultProfile,
defaultServer: action.payload.defaultServer,
};
case 'UPDATE_DATA':
return {
...state,
profile: action.payload.profile,
server: action.payload.server,
transactions: action.payload.transactions,
skipped: action.payload.skipped,
};
case 'SET_STEP':
return {
...state,
wizard: {
...state.wizard,
step: action.payload.step
}
}
case 'MOVE_STEP':
return {
...state,
wizard: {
...state.wizard,
step: state.wizard.step + action.payload.offset
}
}
case 'SET_RESULT':
return {
...state,
result: action.payload
}
}
}

12
web/src/store/state.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { State } from "../types/store";
export const initialState: State = {
availableProfiles: [],
availableServers: [],
transactions: [],
skipped: [],
wizard: {
step: 0
},
};

View File

@@ -7,7 +7,7 @@ export type ConfigResponse = {
export type PrepareResponse = {
transactions: Transaction[];
skipped: string[];
skipped: string[][];
};
export type Transaction = {
@@ -20,4 +20,18 @@ export type Transaction = {
title: string;
id: string;
amount: number;
};
export type ImportOptions =
| { mode: 'add', learnCategories?: boolean, runTransfers?: boolean }
| { mode: 'import' };
export type SubmitResponse = {
errors?: {
message: string;
}[];
added: unknown[];
updated: unknown[];
};

74
web/src/types/store.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { Transaction } from "./api";
export type State = {
availableProfiles: string[];
availableServers: string[];
defaultProfile?: string;
defaultServer?: string;
profile?: string;
server?: string;
transactions: Transaction[];
skipped: string[][];
wizard: {
step: number;
};
result?: {
errors?: {
message: string;
}[];
added: unknown[];
updated: unknown[];
}
};
export type Action =
| {
type: 'UPDATE_CONFIG',
payload: {
profiles: string[],
servers: string[],
defaultProfile?: string,
defaultServer?: string,
}
}
| {
type: 'UPDATE_DATA',
payload: {
profile: string;
server: string;
transactions: Transaction[];
skipped: string[][];
}
}
| {
type: 'SET_STEP',
payload: {
step: number;
}
}
| {
type: 'MOVE_STEP',
payload: {
offset: number;
}
}
| {
type: 'SET_RESULT',
payload: {
errors?: {
message: string;
}[];
added: unknown[];
updated: unknown[];
}
}
export type StoreContext = {
state: State,
dispatch: React.Dispatch<Action>
};

11
web/src/wizard/Wizard.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { useStore } from "../store/AppStore";
export type WizardProps = {
children: React.ReactNode[];
};
export function Wizard({ children }: WizardProps) {
const { state } = useStore();
return children[state.wizard.step];
}

View File

@@ -3,7 +3,7 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2023", "ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,

View File

@@ -1,7 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Actual Importer',
short_name: 'ActualImporter',
description: 'Simple app to import the transactions to Actual Budget system',
theme_color: '#ffffff',
icons: [
{
src: '/public/vite.svg',
sizes: '512x512',
type: 'image/svg',
}
]
},
devOptions: {
enabled: true
}
})
]
})