Compare commits
22 Commits
eb13123d86
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
77590b4b4a
|
|||
|
1f164555be
|
|||
|
32b8d50e5b
|
|||
|
9b24a1d737
|
|||
|
8a6dd58007
|
|||
|
1b6749ef53
|
|||
|
cc208dd748
|
|||
|
e1dc42c254
|
|||
|
490d6aa650
|
|||
|
9d2ac48e1a
|
|||
|
a75e6232be
|
|||
|
af15a352a3
|
|||
|
e3618c539e
|
|||
|
9df89d0ffc
|
|||
|
4959cf1085
|
|||
|
f9cfdbd4a0
|
|||
|
3a5ff132ed
|
|||
|
90b24ec865
|
|||
|
6a557cc060
|
|||
|
80977ee896
|
|||
|
192f21c3a6
|
|||
|
149d8f01b7
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
847
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
25
package.nix
25
package.nix
@@ -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/
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ProfileConfig = {
|
||||
encoding?: string;
|
||||
config?: ParserConfig;
|
||||
csv?: Record<string, unknown>;
|
||||
supportedAccounts?: string[];
|
||||
};
|
||||
|
||||
export type ParserConfig = {
|
||||
|
||||
222
views/index.pug
222
views/index.pug
@@ -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);
|
||||
@@ -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
5235
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ buildNpmPackage {
|
||||
pname = "actual-importer-frontend";
|
||||
version = "0.0.1";
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-CC9mDUyLBPigwlccQMauNoqzHKMeOppPu5y2eJXIzLY=";
|
||||
npmDepsHash = "sha256-Xh+zpYX8u+wKuvBjGc4hxUI2Ed4tiXKC3zk8kFExBbc=";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
17
web/src/hooks/useDebounce.ts
Normal file
17
web/src/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
41
web/src/hooks/useLoader.ts
Normal file
41
web/src/hooks/useLoader.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -9,3 +9,9 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
web/src/pages/PrepareTransactionsPage/FilterPanel.tsx
Normal file
121
web/src/pages/PrepareTransactionsPage/FilterPanel.tsx
Normal 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>
|
||||
</>);
|
||||
}
|
||||
91
web/src/pages/PrepareTransactionsPage/ImportBar.tsx
Normal file
91
web/src/pages/PrepareTransactionsPage/ImportBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.skipped {
|
||||
content: red;
|
||||
}
|
||||
19
web/src/pages/PrepareTransactionsPage/SkippedLines.tsx
Normal file
19
web/src/pages/PrepareTransactionsPage/SkippedLines.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.transactionsTable {
|
||||
.transaction {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
72
web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx
Normal file
72
web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/pages/ResultPage/ResultPage.tsx
Normal file
52
web/src/pages/ResultPage/ResultPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
38
web/src/store/AppStore.tsx
Normal file
38
web/src/store/AppStore.tsx
Normal 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
47
web/src/store/reducer.ts
Normal 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
12
web/src/store/state.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { State } from "../types/store";
|
||||
|
||||
export const initialState: State = {
|
||||
availableProfiles: [],
|
||||
availableServers: [],
|
||||
transactions: [],
|
||||
skipped: [],
|
||||
|
||||
wizard: {
|
||||
step: 0
|
||||
},
|
||||
};
|
||||
@@ -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
74
web/src/types/store.ts
Normal 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
11
web/src/wizard/Wizard.tsx
Normal 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];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user