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
|
||||||
web/dist-ssr
|
web/dist-ssr
|
||||||
web/*.local
|
web/*.local
|
||||||
|
web/dev-dist
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.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/express": "^5.0.1",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/papaparse": "^5.3.15",
|
"@types/papaparse": "^5.3.15",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
"papaparse": "^5.5.2",
|
"papaparse": "^5.5.2",
|
||||||
"pug": "^3.0.3",
|
|
||||||
"yaml": "^2.7.1"
|
"yaml": "^2.7.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
package.nix
25
package.nix
@@ -1,16 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
pkgs,
|
||||||
buildNpmPackage,
|
buildNpmPackage,
|
||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}: let
|
||||||
buildNpmPackage {
|
frontend = pkgs.callPackage ./web/package.nix {};
|
||||||
pname = "actual-importer";
|
in
|
||||||
version = "0.0.1";
|
buildNpmPackage {
|
||||||
src = ./.;
|
pname = "actual-importer";
|
||||||
npmDepsHash = "sha256-QqSZJuQLPll3qFi5Lv4kbQo+548l3VKgx073WLNXMsw=";
|
version = "0.0.1";
|
||||||
|
src = ./.;
|
||||||
|
npmDepsHash = "sha256-SCXZr/Lpyzx8RoUnaM9hsBe5bLJ2xxRzruQv/2Lbetg=";
|
||||||
|
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
mkdir -p $out/views
|
mkdir -p $out/lib/node_modules/actual-importer/public
|
||||||
cp -r ${./views}/* $out/views/
|
cp -r ${frontend}/lib/node_modules/web/dist/* $out/lib/node_modules/actual-importer/public/
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export type SubmitOptions =
|
|||||||
|
|
||||||
type ActualTransaction = {
|
type ActualTransaction = {
|
||||||
id?: string;
|
id?: string;
|
||||||
account?: string;
|
account: string;
|
||||||
date: string;
|
date: string;
|
||||||
amount?: number;
|
amount: number;
|
||||||
payee?: string;
|
payee?: string;
|
||||||
payee_name?: string;
|
payee_name?: string;
|
||||||
imported_payee?: string;
|
imported_payee?: string;
|
||||||
@@ -51,7 +51,7 @@ export class Actual {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#map(transaction: Transaction, accounts: ActualAccount[], payees: ActualPayee[]): ActualTransaction {
|
#map(transaction: Transaction, accounts: ActualAccount[], payees: ActualPayee[]): ActualTransaction {
|
||||||
const actualTransaction: ActualTransaction = {
|
const actualTransaction: Omit<ActualTransaction, 'account'> & { account?: string } = {
|
||||||
imported_id: transaction.id,
|
imported_id: transaction.id,
|
||||||
date: transaction.date,
|
date: transaction.date,
|
||||||
amount: utils.amountToInteger(transaction.amount),
|
amount: utils.amountToInteger(transaction.amount),
|
||||||
@@ -94,7 +94,7 @@ export class Actual {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return actualTransaction;
|
return actualTransaction as ActualTransaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #api<T>(fn: () => Promise<T>): Promise<T> {
|
async #api<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
@@ -186,6 +186,74 @@ export class Actual {
|
|||||||
return this.#doSubmit(prepared, opts);
|
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> {
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
|||||||
@@ -15,6 +15,29 @@ export type ImportResult = PrepareResult & {
|
|||||||
result: ActualImportResult;
|
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) => {
|
export const prepareTransactions = async (stream: Readable, profile: string, server: string, config: Config): Promise<PrepareResult> => new Promise((resolve, reject) => {
|
||||||
const profileConfig = config.profiles[profile];
|
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 { Config } from "@/types/config";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { Transaction } from "@/types/transaction";
|
||||||
|
|
||||||
|
|
||||||
export function serve(config: Config, port: number) {
|
export function serve(config: Config, port: number) {
|
||||||
const app = express();
|
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() });
|
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) => {
|
app.get("/config", (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
profiles: Object.keys(config.profiles),
|
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) => {
|
app.post("/prepare", upload.single("file"), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
throw new Error("No file to upload");
|
throw new Error("No file to upload");
|
||||||
@@ -61,7 +64,12 @@ export function serve(config: Config, port: number) {
|
|||||||
res.send(response);
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server running on ${port} port`);
|
console.log(`Server running on ${port} port`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type ProfileConfig = {
|
|||||||
encoding?: string;
|
encoding?: string;
|
||||||
config?: ParserConfig;
|
config?: ParserConfig;
|
||||||
csv?: Record<string, unknown>;
|
csv?: Record<string, unknown>;
|
||||||
|
supportedAccounts?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParserConfig = {
|
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" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Actual Importer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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": {
|
"dependencies": {
|
||||||
"bulma": "^1.0.4",
|
"bulma": "^1.0.4",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/classnames": "^2.3.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.30.1",
|
"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";
|
pname = "actual-importer-frontend";
|
||||||
version = "0.0.1";
|
version = "0.0.1";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
npmDepsHash = "sha256-CC9mDUyLBPigwlccQMauNoqzHKMeOppPu5y2eJXIzLY=";
|
npmDepsHash = "sha256-Xh+zpYX8u+wKuvBjGc4hxUI2Ed4tiXKC3zk8kFExBbc=";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import LoadFilePage from "./pages/LoadFilePage/LoadFilePage";
|
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() {
|
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 />
|
<App />
|
||||||
</StrictMode>,
|
</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";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
export type LoadFileFormProps = {
|
export type LoadFileFormProps = {
|
||||||
profiles: string[];
|
profiles: string[];
|
||||||
servers: string[];
|
servers: string[];
|
||||||
|
loading: boolean;
|
||||||
defaultProfile?: string;
|
defaultProfile?: string;
|
||||||
defaultServer?: string;
|
defaultServer?: string;
|
||||||
onSubmit?: (csvFile: File, profile: string, server: string) => Promise<void>;
|
onSubmit?: (csvFile: File, profile: string, server: string) => Promise<void>;
|
||||||
@@ -14,7 +16,7 @@ type Form = {
|
|||||||
server?: string;
|
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 fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<Form>({});
|
const [formData, setFormData] = useState<Form>({});
|
||||||
@@ -36,22 +38,24 @@ export default function LoadFileForm({ profiles, servers, defaultProfile, defaul
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [formData.files]);
|
}, [formData.files]);
|
||||||
|
|
||||||
|
const isValid = useMemo(() => selectedFile && formData.profile && formData.server, [formData, selectedFile]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedFile || !formData.profile || !formData.server) {
|
if (!selectedFile || !formData.profile || !formData.server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit?.(selectedFile, formData.profile, formData.server);
|
onSubmit?.(selectedFile, formData.profile, formData.server);
|
||||||
}, [formData, formData, selectedFile, onSubmit]);
|
}, [formData, selectedFile, onSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">CSV File</label>
|
<label className="label">CSV File</label>
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<div className={`file is-fullwidth ${!!selectedFile ? "has-name" : ""}`}>
|
<div className={classNames("file", "is-fullwidth", { "has-name": selectedFile })}>
|
||||||
<label className="file-label">
|
<label className="file-label">
|
||||||
<input className="file-input" ref={fileInput} type="file" accept=".csv" onChange={(e) => setFormData({...formData, files: e.target.files ?? undefined})} required />
|
<input className="file-input" ref={fileInput} type="file" accept=".csv" onChange={(e) => setFormData({...formData, files: e.target.files ?? undefined})} required />
|
||||||
<span className="file-cta">
|
<span className="file-cta">
|
||||||
@@ -95,9 +99,8 @@ export default function LoadFileForm({ profiles, servers, defaultProfile, defaul
|
|||||||
<div className="control">
|
<div className="control">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="button is-link is-fullwidth"
|
className={classNames("button", "is-link", "is-fullwidth", { 'is-loading': loading })}
|
||||||
>
|
disabled={!isValid || loading}>Load transactions
|
||||||
Load transactions
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import LoadFileForm from './LoadFileForm';
|
import LoadFileForm from './LoadFileForm';
|
||||||
import { fetchConfig, loadTransactions } from '../../services/api.service';
|
import { fetchConfig, loadTransactions } from '../../services/api.service';
|
||||||
|
import { useStore } from '../../store/AppStore';
|
||||||
|
import { useLoader } from '../../hooks/useLoader';
|
||||||
|
|
||||||
export default function LoadFilePage() {
|
export default function LoadFilePage() {
|
||||||
|
const { dispatch } = useStore();
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState<string[]>([]);
|
const [profiles, setProfiles] = useState<string[]>([]);
|
||||||
const [servers, setServers] = useState<string[]>([]);
|
const [servers, setServers] = useState<string[]>([]);
|
||||||
@@ -16,23 +19,44 @@ export default function LoadFilePage() {
|
|||||||
setServers(config.servers);
|
setServers(config.servers);
|
||||||
setDefaultProfile(config.defaultProfile);
|
setDefaultProfile(config.defaultProfile);
|
||||||
setDefaultServer(config.defaultServer);
|
setDefaultServer(config.defaultServer);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_CONFIG',
|
||||||
|
payload: config
|
||||||
|
});
|
||||||
}, [setProfiles, setServers, setDefaultProfile, setDefaultServer]);
|
}, [setProfiles, setServers, setDefaultProfile, setDefaultServer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConfig();
|
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);
|
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 (
|
return (
|
||||||
<div className="columns mt-6">
|
<div className="columns mt-6">
|
||||||
<div className="column is-6 is-offset-3">
|
<div className="column is-6 is-offset-3">
|
||||||
<div className="box">
|
<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
|
<LoadFileForm
|
||||||
|
loading={loading}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
servers={servers}
|
servers={servers}
|
||||||
defaultProfile={defaultProfile}
|
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> {
|
export async function fetchConfig(): Promise<ConfigResponse> {
|
||||||
const response = await fetch("http://localhost:3000/config")
|
const response = await fetch("/config")
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data as ConfigResponse;
|
return data as ConfigResponse;
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,58 @@ export async function loadTransactions(csvFile: File, profile: string, server: s
|
|||||||
payload.append("profile", profile);
|
payload.append("profile", profile);
|
||||||
payload.append("server", server);
|
payload.append("server", server);
|
||||||
|
|
||||||
const response = await fetch("http://localhost:3000/prepare", {
|
const response = await fetch("/prepare", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: payload
|
body: payload
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data as PrepareResponse;
|
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 = {
|
export type PrepareResponse = {
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
skipped: string[];
|
skipped: string[][];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Transaction = {
|
export type Transaction = {
|
||||||
@@ -20,4 +20,18 @@ export type Transaction = {
|
|||||||
title: string;
|
title: string;
|
||||||
id: string;
|
id: string;
|
||||||
amount: number;
|
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",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "ES2020", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
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