From 192f21c3a6938fc7c9a08ade5ee3163f29e4fa99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Pluta?= Date: Fri, 9 May 2025 14:32:05 +0200 Subject: [PATCH] Create working PoC of frontend app --- web/package.nix | 2 +- web/src/App.tsx | 9 ++- web/src/pages/LoadFilePage/LoadFilePage.tsx | 7 ++ .../PrepareTransactionsPage/ImportBar.tsx | 77 +++++++++++++++++++ .../PrepareTransactionsPage.tsx | 73 ++++++++++++++++++ .../SkippedLines.module.css | 3 + .../PrepareTransactionsPage/SkippedLines.tsx | 19 +++++ .../TransactionTable.module.css | 7 ++ .../TransactionsTable.tsx | 63 +++++++++++++++ web/src/pages/ResultPage/ResultPage.tsx | 47 +++++++++++ web/src/services/api.service.ts | 25 +++++- web/src/store/AppStore.tsx | 32 +------- web/src/store/reducer.ts | 59 ++++++++++++++ web/src/store/state.ts | 14 ++++ web/src/types/api.ts | 16 +++- web/src/types/store.ts | 55 ++++++++++++- web/src/wizard/Wizard.tsx | 11 +++ 17 files changed, 482 insertions(+), 37 deletions(-) create mode 100644 web/src/pages/PrepareTransactionsPage/ImportBar.tsx create mode 100644 web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx create mode 100644 web/src/pages/PrepareTransactionsPage/SkippedLines.module.css create mode 100644 web/src/pages/PrepareTransactionsPage/SkippedLines.tsx create mode 100644 web/src/pages/PrepareTransactionsPage/TransactionTable.module.css create mode 100644 web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx create mode 100644 web/src/pages/ResultPage/ResultPage.tsx create mode 100644 web/src/store/reducer.ts create mode 100644 web/src/store/state.ts create mode 100644 web/src/wizard/Wizard.tsx diff --git a/web/package.nix b/web/package.nix index 8f93a07..a3f3a1b 100644 --- a/web/package.nix +++ b/web/package.nix @@ -7,5 +7,5 @@ buildNpmPackage { pname = "actual-importer-frontend"; version = "0.0.1"; src = ./.; - npmDepsHash = "sha256-CC9mDUyLBPigwlccQMauNoqzHKMeOppPu5y2eJXIzLY="; + npmDepsHash = "sha256-+HDXY0d3gvXJWy4GPa6vCIqLnq1mCP8kimek5Zl8u80="; } diff --git a/web/src/App.tsx b/web/src/App.tsx index f02c671..10809fe 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +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 ( - + + + + + ); } diff --git a/web/src/pages/LoadFilePage/LoadFilePage.tsx b/web/src/pages/LoadFilePage/LoadFilePage.tsx index d4f299a..0eb26e3 100644 --- a/web/src/pages/LoadFilePage/LoadFilePage.tsx +++ b/web/src/pages/LoadFilePage/LoadFilePage.tsx @@ -40,6 +40,13 @@ export default function LoadFilePage() { ...data } }); + + dispatch({ + type: 'MOVE_STEP', + payload: { + offset: 1 + } + }); }, []); return ( diff --git a/web/src/pages/PrepareTransactionsPage/ImportBar.tsx b/web/src/pages/PrepareTransactionsPage/ImportBar.tsx new file mode 100644 index 0000000..ae461bb --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/ImportBar.tsx @@ -0,0 +1,77 @@ +import { useCallback, useState } from "react"; +import type { ImportOptions } from "../../types/api"; + +export type ImportBarProps = { + onSubmit: (opts: ImportOptions) => void; +}; + +export default function ImportBar({ onSubmit }: ImportBarProps) { + const [formData, setFormData] = useState({ mode: 'import' }); + + + 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 ( +
+
+ +
+
+ + +
+
+
+ + {formData.mode === 'add' &&
+ +
+ +
+
+ +
+
} + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx b/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx new file mode 100644 index 0000000..3f4290e --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx @@ -0,0 +1,73 @@ +import { useCallback, useMemo } from "react"; +import { useStore } from "../../store/AppStore"; +import TransactionsTable from "./TransactionsTable"; +import { SkippedLines } from "./SkippedLines"; +import ImportBar from "./ImportBar"; +import type { ImportOptions } from "../../types/api"; +import { submitTransactions } from "../../services/api.service"; + + +export default function PrepareTransactionsPage() { + const { state, dispatch } = useStore(); + + const desiredTransactions = useMemo(() => state.transactions.filter((_, i) => !state.filteredOut.includes(i)), [state, state.filteredOut]); + + const handleSubmit = useCallback(async (opts: ImportOptions) => { + if(!state.server) { + return; + } + + const response = await submitTransactions(desiredTransactions, state.server, opts); + + dispatch({ + type: 'SET_RESULT', + payload: response + }); + + dispatch({ + type: 'MOVE_STEP', + payload: { + offset: 1 + } + }); + }, [desiredTransactions, state]); + + const filterOut = useCallback((index: number) => dispatch({ + type: 'FILTER_OUT', + payload: { index } + }), [dispatch]); + + const removeFilter = useCallback((index: number) => dispatch({ + type: 'REMOVE_FILTER', + payload: { index } + }), [dispatch]); + + return ( +
+
+
+

Prepare Transactions

+ +
+

Considered transactions ({state.transactions.length})

+ +
+ +
+

Skipped lines ({state.skipped.length})

+ +
+ +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/SkippedLines.module.css b/web/src/pages/PrepareTransactionsPage/SkippedLines.module.css new file mode 100644 index 0000000..96d315a --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/SkippedLines.module.css @@ -0,0 +1,3 @@ +.skipped { + content: red; +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/SkippedLines.tsx b/web/src/pages/PrepareTransactionsPage/SkippedLines.tsx new file mode 100644 index 0000000..afb448f --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/SkippedLines.tsx @@ -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 ( +
+
+        {skipped.map(s => `▶️ ${s.join("🔸")}`).join("\n")}
+      
+
+ ); +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/TransactionTable.module.css b/web/src/pages/PrepareTransactionsPage/TransactionTable.module.css new file mode 100644 index 0000000..7125128 --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/TransactionTable.module.css @@ -0,0 +1,7 @@ +.transactionTable tbody tr { + cursor: pointer; +} + +.disabled td { + color: var(--bulma-text-light); +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx b/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx new file mode 100644 index 0000000..2c2bc7c --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import type { Transaction } from "../../types/api"; +import styles from "./TransactionTable.module.css"; + +export type TransactionsTableProps = { + transactions: Transaction[]; + filteredOut: number[]; + filterOut: (index: number) => void; + removeFilter: (index: number) => void; + readonly?: boolean; +} + +export default function TransactionsTable({ transactions, filteredOut, filterOut, removeFilter, readonly }: TransactionsTableProps) { + + const changeSelection = useCallback((index: number) => { + if (readonly) { + return; + } + + if(filteredOut.includes(index)) { + removeFilter(index); + } + + else { + filterOut(index); + } + }, [filteredOut, filterOut, removeFilter]); + + + const renderRow = useCallback((transaction: Transaction, index: number) => ( + changeSelection(index)} className={`${!readonly && filteredOut.includes(index) ? styles.disabled : ""}`}> + {transaction.kind} + {transaction.date} + {transaction.from} + {transaction.to} + {transaction.amount} + {transaction.title} + {transaction.id} + + ), [changeSelection]); + + return ( +
+ + + + + + + + + + + + + + + {transactions.map(renderRow)} + +
KindDateFromToAmountTitleID
+
+ ); +} \ No newline at end of file diff --git a/web/src/pages/ResultPage/ResultPage.tsx b/web/src/pages/ResultPage/ResultPage.tsx new file mode 100644 index 0000000..4fc12cc --- /dev/null +++ b/web/src/pages/ResultPage/ResultPage.tsx @@ -0,0 +1,47 @@ +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 ( +
+

Errors:

+
    + {state.result.errors.map(e =>
  • {e.message}
  • )} +
+
+ ) + }, [state.result?.errors]); + + return ( +
+
+
+

Import Result

+
+
+
    +
  • Added: {state.result?.added?.length ?? 0}
  • +
  • Updated: {state.result?.updated?.length ?? 0}
  • +
  • Errors: {state.result?.errors?.length ?? 0}
  • +
+
+
+
+ {errors} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/services/api.service.ts b/web/src/services/api.service.ts index b7d8833..616b4f2 100644 --- a/web/src/services/api.service.ts +++ b/web/src/services/api.service.ts @@ -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 { - 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,30 @@ 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 { + 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; } \ No newline at end of file diff --git a/web/src/store/AppStore.tsx b/web/src/store/AppStore.tsx index 96d1119..4c1b0b5 100644 --- a/web/src/store/AppStore.tsx +++ b/web/src/store/AppStore.tsx @@ -1,5 +1,7 @@ -import type { State, Action, StoreContext } from "../types/store"; +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; @@ -8,34 +10,6 @@ export type AppStoreProviderProps = { export type UseStoreReturnType = StoreContext & { }; -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, - }; - } -} - -const initialState: State = { - availableProfiles: [], - availableServers: [], - transactions: [], - skipped: [], -}; const AppContext = createContext({ state: initialState, diff --git a/web/src/store/reducer.ts b/web/src/store/reducer.ts new file mode 100644 index 0000000..b28484e --- /dev/null +++ b/web/src/store/reducer.ts @@ -0,0 +1,59 @@ +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 'FILTER_OUT': + return { + ...state, + filteredOut: [...state.filteredOut, action.payload.index] + } + + case 'REMOVE_FILTER': + return { + ...state, + filteredOut: state.filteredOut.filter(x => x !== action.payload.index) + } + + case 'SET_RESULT': + return { + ...state, + result: action.payload + } + } +} \ No newline at end of file diff --git a/web/src/store/state.ts b/web/src/store/state.ts new file mode 100644 index 0000000..4e6af4b --- /dev/null +++ b/web/src/store/state.ts @@ -0,0 +1,14 @@ +import type { State } from "../types/store"; + +export const initialState: State = { + availableProfiles: [], + availableServers: [], + transactions: [], + skipped: [], + + wizard: { + step: 0 + }, + + filteredOut: [] +}; \ No newline at end of file diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 116bb36..16372ad 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -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[]; }; \ No newline at end of file diff --git a/web/src/types/store.ts b/web/src/types/store.ts index 8a9690e..b8b7391 100644 --- a/web/src/types/store.ts +++ b/web/src/types/store.ts @@ -8,7 +8,22 @@ export type State = { profile?: string; server?: string; transactions: Transaction[]; - skipped: string[]; + skipped: string[][]; + + wizard: { + step: number; + }; + + filteredOut: number[]; + + result?: { + errors?: { + message: string; + }[]; + + added: unknown[]; + updated: unknown[]; + } }; export type Action = @@ -27,7 +42,43 @@ export type Action = profile: string; server: string; transactions: Transaction[]; - skipped: string[]; + skipped: string[][]; + } +} +| { + type: 'SET_STEP', + payload: { + step: number; + } +} +| { + type: 'MOVE_STEP', + payload: { + offset: number; + } +} +| { + type: 'FILTER_OUT', + payload: { + index: number; + }; +} +| { + type: 'REMOVE_FILTER', + payload: { + index: number; + }; +} +| { + type: 'SET_RESULT', + payload: { + errors?: { + message: string; + }[]; + + added: unknown[]; + + updated: unknown[]; } } diff --git a/web/src/wizard/Wizard.tsx b/web/src/wizard/Wizard.tsx new file mode 100644 index 0000000..9cd1c21 --- /dev/null +++ b/web/src/wizard/Wizard.tsx @@ -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]; +} \ No newline at end of file