├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── index.html ├── other └── HN Screenshot.png ├── package.json ├── public ├── 404.html ├── favicon.png ├── logo_144x144.png ├── manifest.json └── robots.txt ├── screenshot.png ├── src ├── app │ ├── context.tsx │ ├── error.tsx │ ├── index.tsx │ ├── navbar.tsx │ ├── notifications.tsx │ ├── popups.tsx │ ├── tutorial.tsx │ └── view.tsx ├── components │ ├── display │ │ ├── BasicBarChart.tsx │ │ ├── BasicFillbar.tsx │ │ ├── CategoryMenu.tsx │ │ ├── FlexWidthChart.tsx │ │ ├── NonIdealState.tsx │ │ ├── ObjectDisplay.tsx │ │ ├── PerformantCharts.tsx │ │ └── SummaryNumber.tsx │ ├── inputs │ │ ├── index.tsx │ │ ├── objects.tsx │ │ └── values.tsx │ ├── layout │ │ ├── index.tsx │ │ ├── page.tsx │ │ └── section.tsx │ ├── snapshot │ │ ├── data.tsx │ │ └── index.tsx │ ├── summary │ │ ├── bar.tsx │ │ ├── breakdown │ │ │ ├── index.tsx │ │ │ ├── pie.tsx │ │ │ └── value.tsx │ │ ├── data.tsx │ │ ├── index.tsx │ │ └── shared.tsx │ └── table │ │ ├── containers │ │ ├── TableContainer.tsx │ │ └── TableHeaderContainer.tsx │ │ ├── filters │ │ ├── FilterIcon.tsx │ │ ├── FilterMenuNestedOption.tsx │ │ ├── FilterMenuOption.tsx │ │ ├── RangeFilters.tsx │ │ └── shared.ts │ │ ├── index.tsx │ │ └── table │ │ ├── data.tsx │ │ ├── edit.tsx │ │ ├── header.tsx │ │ ├── index.tsx │ │ ├── inputs.tsx │ │ ├── styles.tsx │ │ ├── types.tsx │ │ └── view.tsx ├── dialog │ ├── header.tsx │ ├── import │ │ ├── account.tsx │ │ ├── contents │ │ │ ├── file.tsx │ │ │ ├── shared.tsx │ │ │ ├── table │ │ │ │ ├── header.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── shared.tsx │ │ │ │ └── transfer.tsx │ │ │ └── tabs.tsx │ │ ├── import.tsx │ │ ├── index.tsx │ │ ├── steps │ │ │ ├── final.tsx │ │ │ ├── mapping.tsx │ │ │ ├── parse.tsx │ │ │ └── shared.tsx │ │ └── upload.tsx │ ├── index.tsx │ ├── objects │ │ ├── accounts.tsx │ │ ├── categories.tsx │ │ ├── currencies.tsx │ │ ├── institutions.tsx │ │ ├── rules.tsx │ │ ├── shared │ │ │ ├── draggable.tsx │ │ │ ├── edit.tsx │ │ │ ├── index.tsx │ │ │ ├── selector.tsx │ │ │ ├── shared.tsx │ │ │ └── update.ts │ │ └── statements.tsx │ ├── settings │ │ ├── about.tsx │ │ ├── currency.tsx │ │ ├── data.tsx │ │ ├── debug.tsx │ │ ├── dropbox.svg │ │ ├── history.tsx │ │ ├── index.tsx │ │ ├── notifications.tsx │ │ ├── shared.tsx │ │ ├── storage.tsx │ │ └── summary.tsx │ └── shared │ │ ├── TimeSeriesInput.tsx │ │ ├── edits.tsx │ │ ├── index.tsx │ │ └── layout.tsx ├── main.tsx ├── pages │ ├── account │ │ ├── balances.tsx │ │ ├── header.tsx │ │ ├── index.tsx │ │ └── statements.tsx │ ├── accounts │ │ ├── index.tsx │ │ ├── summary.tsx │ │ └── table │ │ │ ├── account.tsx │ │ │ ├── data.tsx │ │ │ ├── header.tsx │ │ │ ├── index.tsx │ │ │ ├── institution.tsx │ │ │ └── styles.tsx │ ├── categories │ │ ├── index.tsx │ │ ├── summary │ │ │ ├── budget.tsx │ │ │ ├── chart.tsx │ │ │ ├── data.tsx │ │ │ ├── index.tsx │ │ │ └── placeholder.tsx │ │ └── table │ │ │ ├── SubCategory.tsx │ │ │ ├── TopLevel.tsx │ │ │ ├── data.tsx │ │ │ ├── header.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ ├── category │ │ ├── budget │ │ │ ├── index.tsx │ │ │ └── transfers.tsx │ │ ├── header.tsx │ │ ├── history.tsx │ │ └── index.tsx │ ├── forecasts │ │ ├── data.tsx │ │ ├── debt.tsx │ │ ├── display.tsx │ │ ├── index.tsx │ │ ├── net.tsx │ │ ├── pension.tsx │ │ └── retirement.tsx │ ├── summary │ │ └── index.tsx │ └── transactions │ │ ├── index.tsx │ │ └── summary.tsx ├── shared │ ├── constants.ts │ ├── data.ts │ ├── events.ts │ ├── hooks.ts │ └── types.ts ├── state │ ├── app │ │ ├── actions.ts │ │ ├── defaults.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── pageTypes.ts │ │ └── statementTypes.ts │ ├── data │ │ ├── demo │ │ │ ├── data.ts │ │ │ ├── icons.ts │ │ │ └── post.ts │ │ ├── hooks.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── shared.ts │ │ └── types.ts │ ├── index.ts │ ├── logic │ │ ├── currencies.ts │ │ ├── database.ts │ │ ├── dropbox.ts │ │ ├── import.ts │ │ ├── notifications │ │ │ ├── index.tsx │ │ │ ├── shared.tsx │ │ │ ├── types.ts │ │ │ └── variants │ │ │ │ ├── accounts.tsx │ │ │ │ ├── currency.tsx │ │ │ │ ├── debt.tsx │ │ │ │ ├── demo.tsx │ │ │ │ ├── dropbox.tsx │ │ │ │ ├── idb.tsx │ │ │ │ ├── milestone.tsx │ │ │ │ └── uncategorised.tsx │ │ ├── startup.ts │ │ └── statement │ │ │ ├── actions.ts │ │ │ ├── index.ts │ │ │ ├── parsing.ts │ │ │ └── types.ts │ └── shared │ │ ├── dailycache.ts │ │ ├── hooks.ts │ │ ├── values.test.ts │ │ └── values.ts ├── styles │ ├── colours.ts │ └── theme.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the main branch 8 | push: 9 | branches: [main] 10 | pull_request: 11 | branches: [main] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 25 | - name: Checkout code to action runner 26 | uses: actions/checkout@v4 27 | 28 | - name: Add node to environment 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: "21" 32 | 33 | - name: Move into the project directory 34 | run: | 35 | cd $GITHUB_WORKSPACE 36 | 37 | - name: Install dependencies 38 | run: | 39 | yarn install --frozen-lockfile 40 | 41 | - name: Build from source 42 | run: | 43 | yarn build 44 | 45 | - name: Deploy to gh-pages branch 46 | run: | 47 | cd dist 48 | git init -b main 49 | git add -A 50 | git config user.name 'GH Action' 51 | git config user.email 'Athenodoros@noreply.github.com' 52 | git commit -m "Package resources for gh-pages" 53 | git push -f https://Athenodoros:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }} main:gh-pages 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TopHat 2 | 3 | TopHat is an offline-first personal finances app. It is designed to let users manage their finances across multiple currencies, in a privacy-preserving way. 4 | 5 | ![Transactions View](screenshot.png) 6 | 7 | ### What is it, technically speaking? 8 | 9 | It's a web app served by GitHub Pages, with no backend service. 10 | 11 | The frontend is a Single Page App bootstrapped with Create-React-App, built using Typescript/React/Redux and some other common libraries (primarily [Victory Charts](https://formidable.com/open-source/victory/) and [Material-UI](https://mui.com/)). It uses a Service Worker for offline behaviour, and IndexedDB (via dexie.js) for storage. 12 | 13 | ### Why have you done this? 14 | 15 | I know, another personal finance app. 16 | 17 | I wanted to track my expenses in a lightweight way, without doing something crazy like giving a 3rd party the passwords to my bank accounts. After looking around, I found that I wanted three main things: 18 | 19 | - Privacy: I don't want my credentials or account details to be sold or used for ad targeting, or subject to the questionable infosec standards of the latest hip FinTech startup. This also rules out direct connections to most banks, so I need a smooth and automated experience for uploading bank statements, to save the manual gruntwork. 20 | - Multi-Currency Support: I hold money in multiple currencies, and need to be able to track them over time. This is strangely uncommon in the world of personal finance apps. 21 | - Transaction Tracking: I don't need a full YNAB-style budgeting workflow, but I do want to be able to track how much I'm spending on bills/recreation/travel over time, and see how the balance between them is changing. 22 | 23 | I couldn't find anything which hit all three requirements, so after many weekends of work, TopHat is now what I'm using. 24 | 25 | ### Should I use this? 26 | 27 | Probably not! The goals of TopHat are fairly niche interests, and there may be bugs that I'm not seeing. That said, I can at least vouch for the fact that I'm using it, and that I see no reason why I would take it down in the future. It's available publicly at [https://athenodoros.github.io/TopHat](https://athenodoros.github.io/TopHat), and it can populate itself with notional data for an easy trial. 28 | 29 | ## Potentially Asked Questions 30 | 31 | **Why have you done this in the browser? Why not a native app?** 32 | 33 | My feelings are basically summarised in [this XKCD](https://xkcd.com/1367/), but the short answer is that deployment, installation, and upgrades are all made extremely easy in the browser. It can be [installed as a Progressive Web App](https://support.google.com/chrome/answer/9658361), if something app-shaped is desired. 34 | 35 | A reasonable concern might be around performance, particularly given that all data is loaded in memory while the app is open. In practice, my experience is that this isn't a major issue even with thousands of transactions, so I haven't spent time on a "more scalable" architecture. 36 | 37 | **Does this work on mobile?** 38 | 39 | It does not: TopHat is designed for periodic updates and inspection, rather than the daily use which would suggest a mobile interface. Mostly this is because I dislike the idea of constant monitoring of finances, but it would also be difficult to sync data between computer and mobile without a backend server. 40 | 41 | **I found a bug/I have a cool idea/I want to say hi!** 42 | 43 | Let me know! No promises, though, although I'll probably say hi back. 44 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | TopHat Finance 12 | 13 | 14 | 28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /other/HN Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/aff58fdbc358c153436581348ad92b2c96a1ed62/other/HN Screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tophat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://athenodoros.github.io/TopHat", 6 | "type": "module", 7 | "dependencies": { 8 | "@date-io/luxon": "^2.16.1", 9 | "@emotion/react": "^11.10.5", 10 | "@emotion/styled": "^11.10.5", 11 | "@fontsource/roboto": "^4.5.8", 12 | "@mui/icons-material": "^5.11.0", 13 | "@mui/lab": "^5.0.0-alpha.119", 14 | "@mui/material": "^5.11.8", 15 | "@mui/x-date-pickers": "^7.3.2", 16 | "@reduxjs/toolkit": "^1.9.2", 17 | "chroma-js": "^2.1.2", 18 | "dexie": "^3.2.3", 19 | "dexie-observable": "^4.0.1-beta.13", 20 | "jszip": "^3.10.1", 21 | "lodash-es": "^4.17.21", 22 | "luxon": "^3.2.1", 23 | "papaparse": "^5.3.1", 24 | "prettier": "^2.8.4", 25 | "react": "^18.2.0", 26 | "react-beautiful-dnd": "^13.1.1", 27 | "react-dom": "^18.2.0", 28 | "react-dropzone": "^14.2.3", 29 | "react-redux": "^8.0.5", 30 | "rfc6902": "^5.1.1", 31 | "victory": "^36.6.8", 32 | "vite-plugin-pwa": "^0.20.0" 33 | }, 34 | "scripts": { 35 | "dev": "vite --host 0.0.0.0", 36 | "build": "tsc && vite build --base=/TopHat/", 37 | "preview": "vite preview", 38 | "test": "vitest" 39 | }, 40 | "devDependencies": { 41 | "@types/chroma-js": "^2.1.5", 42 | "@types/jest": "^29.4.0", 43 | "@types/lodash-es": "^4.17.4", 44 | "@types/luxon": "^3.2.0", 45 | "@types/node": "^18.13.0", 46 | "@types/papaparse": "^5.3.7", 47 | "@types/react": "^18.0.27", 48 | "@types/react-beautiful-dnd": "^13.1.3", 49 | "@types/react-dom": "^18.0.10", 50 | "@types/react-redux": "^7.1.25", 51 | "@types/react-test-renderer": "^18.0.0", 52 | "@vitejs/plugin-react-swc": "^3.0.0", 53 | "jsdom": "^21.1.0", 54 | "typescript": "^4.9.5", 55 | "vite": "^4.1.0", 56 | "vitest": "^1.6.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TopHat Finance 6 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/aff58fdbc358c153436581348ad92b2c96a1ed62/public/favicon.png -------------------------------------------------------------------------------- /public/logo_144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/aff58fdbc358c153436581348ad92b2c96a1ed62/public/logo_144x144.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "TopHat Finance", 3 | "name": "TopHat Finance", 4 | "description": "TopHat is a Personal Finance application which runs in the browser", 5 | "icons": [ 6 | { 7 | "src": "favicon.png", 8 | "sizes": "89x90", 9 | "type": "image/x-icon" 10 | }, 11 | { 12 | "src": "logo_144x144.png", 13 | "type": "image/png", 14 | "sizes": "144x144" 15 | } 16 | ], 17 | "start_url": "/TopHat/summary", 18 | "display": "standalone", 19 | "theme_color": "#7157D9", 20 | "background_color": "#F7FAFC" 21 | } 22 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Athenodoros/TopHat/aff58fdbc358c153436581348ad92b2c96a1ed62/screenshot.png -------------------------------------------------------------------------------- /src/app/context.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, StyledEngineProvider, ThemeProvider } from "@mui/material"; 2 | import { LocalizationProvider } from "@mui/x-date-pickers"; 3 | import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; 4 | import { noop, omit } from "lodash-es"; 5 | import React from "react"; 6 | import { FileRejection, useDropzone } from "react-dropzone"; 7 | import { Provider } from "react-redux"; 8 | import { TopHatDialog } from "../dialog"; 9 | import { FCWithChildren } from "../shared/types"; 10 | import { TopHatStore } from "../state"; 11 | import { handleStatementFileUpload } from "../state/logic/statement"; 12 | import { TopHatTheme } from "../styles/theme"; 13 | import { PageErrorBoundary } from "./error"; 14 | import { PopupDisplay } from "./popups"; 15 | import { TopHatTutorial } from "./tutorial"; 16 | 17 | export const FileHandlerContext = React.createContext<{ 18 | openFileDialog: () => void; 19 | acceptedFiles: File[]; 20 | fileRejections: FileRejection[]; 21 | isDragActive: boolean; 22 | dropzoneRef: React.RefObject | null; 23 | }>({ 24 | openFileDialog: noop, 25 | acceptedFiles: [], 26 | fileRejections: [], 27 | isDragActive: false, 28 | dropzoneRef: null, 29 | }); 30 | 31 | export const TopHatContextProvider: FCWithChildren = ({ children }) => { 32 | const { 33 | open: openFileDialog, 34 | acceptedFiles, 35 | fileRejections, 36 | getRootProps, 37 | getInputProps, 38 | isDragActive, 39 | rootRef: dropzoneRef, 40 | } = useDropzone({ 41 | accept: { "text/csv": [".csv"] }, 42 | onDrop: handleStatementFileUpload, 43 | }); 44 | 45 | return ( 46 | <> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 62 | 63 |
64 | 65 | 66 | 70 | {children} 71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | import { BrokenImage } from "@mui/icons-material"; 2 | import { Link } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import React, { PropsWithChildren } from "react"; 5 | import { NonIdealState } from "../components/display/NonIdealState"; 6 | 7 | export class PageErrorBoundary extends React.Component { 8 | constructor(props: any) { 9 | super(props); 10 | this.state = { hasError: false }; 11 | } 12 | 13 | static getDerivedStateFromError() { 14 | // Update state so the next render will show the fallback UI. 15 | return { hasError: true }; 16 | } 17 | 18 | componentDidCatch(error: any, errorInfo: any) { 19 | // Log error 20 | console.log(error, errorInfo); 21 | } 22 | 23 | render() { 24 | if (this.state.hasError) { 25 | // You can render any custom fallback UI 26 | return ( 27 | 28 | 34 | TopHat has hit an error, and has ended up in a bad state. You could go back to the{" "} 35 | 36 | home page 37 | 38 | , or check the developer tools for more information. 39 | 40 | } 41 | /> 42 | 43 | ); 44 | } 45 | 46 | return this.props.children; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TopHatContextProvider } from "./context"; 3 | import { View } from "./view"; 4 | 5 | export const App: React.FC = () => ( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/app/popups.tsx: -------------------------------------------------------------------------------- 1 | import { Close } from "@mui/icons-material"; 2 | import { Alert, AlertColor, Button, IconButton, Snackbar, SnackbarCloseReason } from "@mui/material"; 3 | import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; 4 | import { FCWithChildren } from "../shared/types"; 5 | 6 | export interface PopupAlert { 7 | message: string; 8 | severity: AlertColor; 9 | duration?: number | null; 10 | action?: { 11 | name: string; 12 | callback: () => void; 13 | }; 14 | } 15 | 16 | export let setPopupAlert = (alert: PopupAlert) => undefined as void; 17 | 18 | const PopupContext = createContext(setPopupAlert); 19 | export const useSetAlert = () => useContext(PopupContext); 20 | 21 | export const PopupDisplay: FCWithChildren = ({ children }) => { 22 | const [alert, setAlert] = useState(); 23 | useEffect(() => void (setPopupAlert = (newAlert: PopupAlert) => setAlert(newAlert)), []); 24 | const close = useCallback(() => setAlert(undefined), []); 25 | const closeAll = useCallback((_: unknown, reason: SnackbarCloseReason) => { 26 | if (reason === "clickaway") return; 27 | setAlert(undefined); 28 | }, []); 29 | 30 | return ( 31 | <> 32 | 37 | {alert && ( 38 | 45 | {alert.action && ( 46 | 54 | )} 55 | {alert.duration !== null ? ( 56 | 57 | 58 | 59 | ) : undefined} 60 | 61 | ) : undefined 62 | } 63 | > 64 | {alert.message} 65 | 66 | )} 67 | 68 | {children} 69 | 70 | ); 71 | }; 72 | 73 | const getWrappedCallback = (callback: () => void, close: () => void) => () => { 74 | callback(); 75 | close(); 76 | }; 77 | -------------------------------------------------------------------------------- /src/app/view.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import React, { useEffect } from "react"; 3 | import { AccountPage } from "../pages/account"; 4 | import { AccountsPage } from "../pages/accounts"; 5 | import { CategoriesPage } from "../pages/categories"; 6 | import { CategoryPage } from "../pages/category"; 7 | import { ForecastPage } from "../pages/forecasts"; 8 | import { SummaryPage } from "../pages/summary"; 9 | import { TransactionsPage } from "../pages/transactions"; 10 | import { TopHatDispatch } from "../state"; 11 | import { PageStateType } from "../state/app/pageTypes"; 12 | import { DataSlice, setSubmitNotification } from "../state/data"; 13 | import { useSelector } from "../state/shared/hooks"; 14 | import { APP_BACKGROUND_COLOUR } from "../styles/theme"; 15 | import { NavBar } from "./navbar"; 16 | import { useSetAlert } from "./popups"; 17 | import { MIN_WIDTH_FOR_APPLICATION } from "./tutorial"; 18 | 19 | export const View: React.FC = () => { 20 | const page = useSelector((state) => state.app.page.id); 21 | const setAlert = useSetAlert(); 22 | useEffect( 23 | () => 24 | setSubmitNotification((id, message, intent) => 25 | setAlert({ 26 | message, 27 | severity: intent || "success", 28 | action: { 29 | name: "UNDO", 30 | callback: () => TopHatDispatch(DataSlice.actions.rewindToPatch(id)), 31 | }, 32 | }) 33 | ), 34 | [setAlert] 35 | ); 36 | 37 | return ( 38 | 39 | 40 | {Pages[page]} 41 | 42 | ); 43 | }; 44 | 45 | const Pages: Record = { 46 | summary: , 47 | accounts: , 48 | account: , 49 | transactions: , 50 | categories: , 51 | category: , 52 | forecasts: , 53 | }; 54 | 55 | const AppContainerBox = styled("div")({ 56 | height: "100vh", 57 | width: "100vw", 58 | minWidth: MIN_WIDTH_FOR_APPLICATION, 59 | display: "flex", 60 | backgroundColor: APP_BACKGROUND_COLOUR, 61 | "& *:focus": { outline: "none" }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/display/BasicBarChart.tsx: -------------------------------------------------------------------------------- 1 | import { lighten, Tooltip } from "@mui/material"; 2 | import { Box, SxProps } from "@mui/system"; 3 | import { identity } from "lodash"; 4 | import React from "react"; 5 | import { getChartDomainFunctions } from "../../shared/data"; 6 | import { Greys, Intents } from "../../styles/colours"; 7 | import { getThemeTransition } from "../../styles/theme"; 8 | 9 | export const getBasicBarChartColour = (success: boolean | null, stub?: boolean) => 10 | success === null 11 | ? stub 12 | ? { main: Greys[700], dark: Greys[700] } 13 | : Intents.primary 14 | : Intents[success ? "success" : "danger"]; 15 | 16 | export const BasicBarChart: React.FC<{ 17 | className?: string; 18 | getMouseOverText?: (value: number) => string; 19 | values: number[]; 20 | selected?: number; 21 | setSelected?: (index: number) => void; 22 | sx?: SxProps; 23 | }> = ({ className, getMouseOverText, sx, values, selected: selectedIndex, setSelected }) => { 24 | const { getPoint, getOffsetAndSizeForRange } = getChartDomainFunctions(values); 25 | const width = (1 / values.length) * 100 + "%"; 26 | 27 | const getColour = (value: number) => 28 | getBasicBarChartColour( 29 | values.some((x) => x < 0) && values.some((x) => x >= 0) ? value >= 0 : null, 30 | !values.some(identity) 31 | ); 32 | 33 | return ( 34 | 35 | {values.map((value, idx) => { 36 | const colour = getColour(value); 37 | const selected = selectedIndex === idx; 38 | const { offset: bottom, size: height } = getOffsetAndSizeForRange(value, 0); 39 | const right = (idx / values.length) * 100 + "%"; 40 | const common = { 41 | position: "absolute" as const, 42 | right, 43 | width, 44 | transition: getThemeTransition("all"), 45 | }; 46 | 47 | return ( 48 | 49 | 50 |
setSelected(idx))} 59 | /> 60 | 61 |
71 |
81 | 82 | ); 83 | })} 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/display/FlexWidthChart.tsx: -------------------------------------------------------------------------------- 1 | import { Box, SxProps } from "@mui/system"; 2 | import React, { useEffect, useState } from "react"; 3 | import { VictoryChart } from "victory"; 4 | import { useDivBoundingRect } from "../../shared/hooks"; 5 | 6 | interface FlexWidthChartProps { 7 | getChart: (width: number) => React.ReactElement; 8 | style?: React.CSSProperties; 9 | sx?: SxProps; 10 | } 11 | export const FlexWidthChart: React.FC = ({ getChart, style = {}, sx }) => { 12 | const [{ width }, ref] = useDivBoundingRect(); 13 | 14 | const [chart, setChart] = useState(); 15 | useEffect(() => { 16 | // The chart is first rendered with a bounding box of 0 * 0. In that case, we return undefined 17 | if (!width) return; 18 | 19 | const chart = getChart(width); 20 | if (!React.isValidElement(chart)) return; 21 | 22 | setChart(React.cloneElement(chart, { width } as any)); 23 | }, [width, getChart]); 24 | 25 | return ( 26 | 27 | {chart} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/display/NonIdealState.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Typography } from "@mui/material"; 3 | import chroma from "chroma-js"; 4 | import React from "react"; 5 | import { IconType } from "../../shared/types"; 6 | import { Intents } from "../../styles/colours"; 7 | 8 | interface NonIdealStateProps { 9 | icon: IconType; 10 | title: string; 11 | intent?: keyof typeof Intents; 12 | subtitle?: React.ReactNode; 13 | action?: React.ReactNode; 14 | } 15 | export const NonIdealState: React.FC = ({ icon: Icon, title, subtitle, intent, action }) => ( 16 | 17 | 23 | {title} 24 | {subtitle ? ( 25 | typeof subtitle === "string" ? ( 26 | {subtitle} 27 | ) : ( 28 | subtitle 29 | ) 30 | ) : undefined} 31 | {action} 32 | 33 | ); 34 | 35 | const ContainerBox = styled("div")({ 36 | display: "flex", 37 | flexDirection: "column", 38 | alignItems: "center", 39 | textAlign: "center", 40 | margin: "auto", 41 | padding: 40, 42 | }); 43 | const IconSx = { margin: 10, height: 50, width: 50 }; 44 | const SubtitleTypography = styled(Typography)({ 45 | opacity: 0.8, 46 | maxWidth: 300, 47 | textAlign: "center", 48 | margin: "5px 0 10px 0", 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/display/PerformantCharts.tsx: -------------------------------------------------------------------------------- 1 | import { DateTime } from "luxon"; 2 | import React from "react"; 3 | import { VictoryAxis, VictoryChartProps } from "victory"; 4 | import { DomainTuple } from "victory-core"; 5 | import { BLACK } from "../../styles/colours"; 6 | 7 | const DummyComponent: React.FC = () =>
; 8 | 9 | export const getHiddenTickZeroAxis = (stroke: string = BLACK) => ( 10 | } 13 | tickLabelComponent={} 14 | axisValue={0.001} // There seems to be a bad falsiness check here, thus 0.001 15 | /> 16 | ); 17 | 18 | const formatDateValuesForAxis = (value: Date) => DateTime.fromJSDate(value).toFormat("LLL yyyy"); 19 | export const getBottomAlignedDateAxisFromDomain = (yDomain: [number, number], flip?: boolean) => 20 | getBottomAlignedDateAxis(yDomain[flip ? 1 : 0]); 21 | export const getBottomAlignedDateAxis = (value: number = 0) => ( 22 | 32 | ); 33 | 34 | // Victory renders charts using an incredibly slow recursive method for many props. 35 | // This fills in some of the major ones manually. 36 | export const getChartPerformanceProps = ( 37 | domain: { x: DomainTuple; y: DomainTuple }, 38 | scale: VictoryChartProps["scale"] = "linear", 39 | categories: VictoryChartProps["categories"] = [] 40 | ) => { 41 | if (!domain) return { domain, scale, categories }; 42 | if (domain.constructor === Array) return { domain: fixEmptyRange(domain), scale, categories }; 43 | 44 | return { 45 | domain: { 46 | x: fixEmptyRange(domain.x), 47 | y: fixEmptyRange(domain.y), 48 | }, 49 | scale, 50 | categories, 51 | }; 52 | }; 53 | const fixEmptyRange = (tuple: DomainTuple | undefined): DomainTuple | undefined => 54 | tuple && typeof tuple[0] === "number" ? (!tuple[0] && !tuple[1] ? [0, 0.1] : tuple) : tuple; 55 | -------------------------------------------------------------------------------- /src/components/display/SummaryNumber.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Typography } from "@mui/material"; 3 | import { Box } from "@mui/system"; 4 | import { NBSP } from "../../shared/constants"; 5 | import { IconType } from "../../shared/types"; 6 | import { AppColours, Greys, Intents, WHITE } from "../../styles/colours"; 7 | 8 | interface SummaryNumberProps { 9 | icon: IconType; 10 | primary: { 11 | value: string; 12 | positive: boolean | null; 13 | }; 14 | secondary?: { 15 | value: string; 16 | positive: boolean | null; 17 | }; 18 | subtext: string; 19 | } 20 | export const SummaryNumber: React.FC = ({ icon: Icon, primary, secondary, subtext }) => ( 21 | 22 | 23 |
24 | 34 | {primary.value} 35 | 36 | 37 | {secondary ? ( 38 | 48 | {secondary.value + NBSP} 49 | 50 | ) : undefined} 51 | 52 | {subtext} 53 | 54 | 55 |
56 |
57 | ); 58 | 59 | const SummaryNumberContainerBox = styled("div")({ 60 | display: "flex", 61 | width: 220, 62 | 63 | padding: "10px 0 20px 0", 64 | "&:last-child": { 65 | paddingBottom: 10, 66 | }, 67 | }); 68 | const IconSx = { 69 | backgroundColor: Greys[600], 70 | width: 38, 71 | height: 38, 72 | padding: 9, 73 | display: "flex", 74 | justifyContent: "center", 75 | alignItems: "center", 76 | color: WHITE, 77 | borderRadius: "50%", 78 | marginRight: 12, 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/inputs/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./objects"; 2 | export * from "./values"; 3 | -------------------------------------------------------------------------------- /src/components/inputs/values.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox, FormControlLabel } from "@mui/material"; 2 | import { SxProps } from "@mui/system"; 3 | import { DatePicker, DatePickerProps } from "@mui/x-date-pickers"; 4 | import { noop } from "lodash"; 5 | import { DateTime } from "luxon"; 6 | import { useCallback, useEffect, useState } from "react"; 7 | import { SDate, formatDate, getNow, parseDate } from "../../state/shared/values"; 8 | 9 | interface SubItemCheckboxProps { 10 | label: string; 11 | checked: boolean; 12 | setChecked: (value: boolean) => void; 13 | left?: boolean; 14 | sx?: SxProps; 15 | disabled?: boolean; 16 | } 17 | export const SubItemCheckbox: React.FC = ({ label, checked, setChecked, left, sx, disabled }) => ( 18 | setChecked(!checked)} 38 | /> 39 | } 40 | label={label} 41 | labelPlacement={left ? "end" : "start"} 42 | disabled={disabled} 43 | /> 44 | ); 45 | 46 | export const ManagedDatePicker = ({ 47 | value: initial, 48 | onChange, 49 | nullable, 50 | maxDate, 51 | minDate, 52 | disableFuture, 53 | disablePast, 54 | ...props 55 | }: Omit>, "value" | "onChange"> & { 56 | value: Nullable extends true ? SDate | undefined : SDate; 57 | onChange: (value: Nullable extends true ? SDate | undefined : SDate) => void; 58 | nullable: Nullable; 59 | }) => { 60 | const [value, setValue] = useState(parseDate(initial) || null); 61 | useEffect(() => setValue(parseDate(initial || null)), [initial]); 62 | 63 | const onChangeHandler = useCallback>["onChange"]>>( 64 | // Either called with null (empty), an invalid DateTime, or a valid DateTime 65 | (newValue: DateTime | null, _context: any) => { 66 | setValue(newValue); 67 | 68 | if (nullable && newValue === null) return (onChange as any)(undefined); 69 | if ( 70 | newValue && 71 | (newValue as DateTime).isValid && 72 | (!minDate || (minDate as DateTime) <= (newValue as DateTime)) && 73 | (!maxDate || (maxDate as DateTime) >= (newValue as DateTime)) && 74 | (!disableFuture || (newValue as DateTime) <= getNow()) && 75 | (!disablePast || (newValue as DateTime) >= getNow()) 76 | ) 77 | return onChange(formatDate(newValue as DateTime)); 78 | }, 79 | [nullable, onChange, minDate, maxDate, disableFuture, disablePast] 80 | ); 81 | 82 | return ( 83 | 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | export { Page } from "./page"; 2 | export * from "./section"; 3 | -------------------------------------------------------------------------------- /src/components/layout/page.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Notifications as NotificationsIcon } from "@mui/icons-material"; 3 | import { Badge, IconButton, Popover, Typography } from "@mui/material"; 4 | import { NAVBAR_LOGO_HEIGHT } from "../../app/navbar"; 5 | import { Notifications } from "../../app/notifications"; 6 | import { usePopoverProps } from "../../shared/hooks"; 7 | import { FCWithChildren } from "../../shared/types"; 8 | import { useNotificationCount } from "../../state/data/hooks"; 9 | 10 | export const Page: FCWithChildren<{ title: string }> = ({ children, title }) => { 11 | const notifications = useNotificationCount(); 12 | const { buttonProps, popoverProps } = usePopoverProps(); 13 | 14 | return ( 15 | 16 | 17 | {title} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | const PageContainerBox = styled("div")({ 39 | display: "flex", 40 | flexDirection: "column", 41 | alignItems: "stretch", 42 | flexGrow: 1, 43 | overflowY: "auto", 44 | padding: "0 60px 200px 60px", 45 | }); 46 | const TitleBox = styled("div")({ 47 | height: NAVBAR_LOGO_HEIGHT, 48 | flexShrink: 0, 49 | paddingTop: 4, 50 | 51 | display: "flex", 52 | alignItems: "center", 53 | justifyContent: "space-between", 54 | }); 55 | const TitleButtonsBox = styled("div")({ 56 | "& > button": { 57 | marginLeft: 15, 58 | borderRadius: "50%", 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/layout/section.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Paper, Typography } from "@mui/material"; 3 | import { Box, SxProps } from "@mui/system"; 4 | import React from "react"; 5 | import { FCWithChildren } from "../../shared/types"; 6 | import { Greys } from "../../styles/colours"; 7 | import { getThemeTransition } from "../../styles/theme"; 8 | 9 | export const SECTION_MARGIN = 40; 10 | 11 | export interface SectionProps { 12 | title?: string; 13 | headers?: React.ReactNode | React.ReactNode[]; 14 | emptyBody?: boolean; 15 | onClick?: () => void; 16 | sx?: SxProps; 17 | PaperSx?: SxProps; 18 | } 19 | export const Section: FCWithChildren = ({ 20 | title, 21 | headers, 22 | children, 23 | emptyBody, 24 | onClick, 25 | sx, 26 | PaperSx, 27 | }) => { 28 | return ( 29 | 30 | {title || headers ? ( 31 | 32 | {title} 33 | {headers} 34 | 35 | ) : undefined} 36 | {emptyBody ? ( 37 | children 38 | ) : ( 39 | 40 | {children} 41 | 42 | )} 43 | 44 | ); 45 | }; 46 | 47 | const SectionBox = styled(Box)({ 48 | display: "flex", 49 | flexDirection: "column", 50 | justifyContent: "stretch", 51 | }); 52 | const SectionHeaderBox = styled("div")({ 53 | display: "flex", 54 | justifyContent: "space-between", 55 | alignItems: "center", 56 | marginBottom: 12, 57 | height: 32, 58 | flexShrink: 0, 59 | zIndex: 3, // For tables, so that the title is visible over the raised header 60 | 61 | "& > h6": { 62 | color: Greys[600], 63 | }, 64 | 65 | "& button": { 66 | color: Greys[600] + " !important", 67 | transition: getThemeTransition("color"), 68 | }, 69 | 70 | "& > div:last-child > *": { 71 | marginLeft: 20, 72 | }, 73 | }); 74 | const SectionBodyPaper = styled(Paper)({ 75 | marginBottom: 50, 76 | flexGrow: 1, 77 | padding: 20, 78 | }); 79 | -------------------------------------------------------------------------------- /src/components/snapshot/data.tsx: -------------------------------------------------------------------------------- 1 | import { range, sum, toPairs, unzip, zip } from "lodash"; 2 | import { useMemo } from "react"; 3 | import { equalZip, takeWithDefault } from "../../shared/data"; 4 | import { useAllAccounts, useAllCategories } from "../../state/data/hooks"; 5 | import { TRANSFER_CATEGORY_ID } from "../../state/data/shared"; 6 | import { ID } from "../../state/shared/values"; 7 | 8 | export interface SnapshotSectionData { 9 | trends: { 10 | credits: number[]; 11 | debits: number[]; 12 | }; 13 | net: number[]; 14 | currency?: ID; 15 | } 16 | 17 | export const useAssetsSnapshot = (account?: ID, currency?: ID) => { 18 | const accounts = useAllAccounts(); 19 | 20 | return useMemo( 21 | () => 22 | getSnapshotDisplayValues( 23 | accounts 24 | .filter(({ id }) => account === undefined || id === account) 25 | .flatMap(({ balances }) => 26 | toPairs(balances) 27 | .filter(([id, _]) => currency === undefined || currency === Number(id)) 28 | .map(([_, balance]) => balance[currency === undefined ? "localised" : "original"]) 29 | ) 30 | .reduce( 31 | (accs, balances) => 32 | zip(accs, balances).map(([acc, bal]) => { 33 | const [pos, neg] = acc || ([0, 0] as [number, number]); 34 | return (bal && bal > 0 ? [pos + bal, neg] : [pos, neg + (bal || 0)]) as [ 35 | number, 36 | number 37 | ]; 38 | }), 39 | [] as [number, number][] 40 | ), 41 | currency 42 | ), 43 | [accounts, account, currency] 44 | ); 45 | }; 46 | 47 | export const useTransactionsSnapshot = (category?: ID): SnapshotSectionData => { 48 | const categories = useAllCategories(); 49 | 50 | return useMemo( 51 | () => 52 | getSnapshotDisplayValues( 53 | unzip( 54 | categories 55 | .filter(({ id }) => id !== TRANSFER_CATEGORY_ID) 56 | .filter(({ id }) => category === undefined || id === category) 57 | .flatMap(({ transactions }) => transactions) 58 | .reduce( 59 | ([accCredits, accDebits], { credits, debits }) => 60 | [ 61 | zip(accCredits, credits).map(([acc, val]) => (acc || 0) + (val || 0)), 62 | zip(accDebits, debits).map(([acc, val]) => (acc || 0) + (val || 0)), 63 | ] as [number[], number[]], 64 | [[], []] as [number[], number[]] 65 | ) 66 | ) as [number, number][] 67 | ), 68 | [categories, category] 69 | ); 70 | }; 71 | 72 | const getSnapshotDisplayValues = (trends: [number, number][], currency?: ID) => { 73 | let [credits, debits] = trends.length ? unzip(trends) : [range(12).map((_) => 0), range(12).map((_) => 0)]; 74 | credits = takeWithDefault(credits, 25, 0); 75 | debits = takeWithDefault(debits, 25, 0); 76 | 77 | const net = equalZip(credits, debits).map(sum); 78 | return { trends: { credits, debits }, net, currency }; 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/summary/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { SECTION_MARGIN } from "../layout"; 3 | 4 | export * from "./bar"; 5 | export * from "./breakdown"; 6 | export * from "./data"; 7 | 8 | export const SummarySection = styled("div")({ 9 | display: "flex", 10 | 11 | "& > div:first-of-type": { 12 | flex: "300px 0 0", 13 | marginRight: SECTION_MARGIN, 14 | }, 15 | 16 | "& > div:last-child": { 17 | flexGrow: 1, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/summary/shared.tsx: -------------------------------------------------------------------------------- 1 | import chroma from "chroma-js"; 2 | import React from "react"; 3 | import { VictoryStyleInterface } from "victory-core"; 4 | import { suppressEvent } from "../../shared/events"; 5 | 6 | export type SummaryChartSign = "credits" | "debits"; 7 | export interface ChartPoint { 8 | id: number; 9 | colour: string; 10 | sign: SummaryChartSign; 11 | } 12 | export interface ChartPointEvent { 13 | style: React.CSSProperties; 14 | datum: ChartPoint; 15 | } 16 | export const getChartEvents = ( 17 | onClick: (t: T) => void, 18 | highlightSeries: boolean = false 19 | ) => { 20 | const mutation = (alpha?: number, transition?: string) => ({ 21 | eventKey: highlightSeries ? "all" : undefined, 22 | mutation: (event: T) => { 23 | if (!alpha || !event) return; 24 | 25 | /** 26 | * event.datum should never be undefined, but Victory seems to have a bug where it doesn't update the 27 | * data when new props are given (mostly around starting the tutorial on the Transactions page). 28 | */ 29 | if (!event.datum) return; 30 | 31 | return { 32 | style: Object.assign({}, event.style, { fill: fadeColour(event.datum.colour, alpha), transition }), 33 | }; 34 | }, 35 | }); 36 | 37 | return [ 38 | { 39 | childName: "all", 40 | target: "data" as const, 41 | eventHandlers: { 42 | onMouseEnter: () => mutation(0.75, "none"), 43 | onMouseOut: () => mutation(), 44 | // onMouseDown has two, so that the styling obeys highlightSeries but onClick only triggers once 45 | onMouseDown: () => [mutation(1, "none"), { mutation: onClick }], 46 | onMouseUp: () => mutation(0.75, "fill 500ms cubic-bezier(0.4, 0, 0.2, 1) 0ms"), 47 | onClick: (event: React.SyntheticEvent) => { 48 | suppressEvent(event); 49 | return []; 50 | }, 51 | }, 52 | }, 53 | ]; 54 | }; 55 | 56 | const fadeColour = (colour: string, value: number) => colour && chroma(colour).alpha(value).hex(); 57 | 58 | export const getChartSectionStyles = (interactive: boolean): VictoryStyleInterface => ({ 59 | data: { 60 | cursor: interactive ? "pointer" : undefined, 61 | // Sometimes datum.colour is stripped for zero-height sections 62 | fill: ({ datum }) => fadeColour(datum.colour, 0.5)!, 63 | stroke: ({ datum }) => datum.colour, 64 | strokeWidth: 1, 65 | transition: "fill 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms", 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/components/table/containers/TableContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Section, SectionProps } from "../../layout"; 3 | 4 | type TableContainerProps = Pick; 5 | export const TableContainer: React.FC = (props) =>
; 6 | -------------------------------------------------------------------------------- /src/components/table/containers/TableHeaderContainer.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Card, Theme } from "@mui/material"; 3 | import { SxProps } from "@mui/system"; 4 | import { FCWithChildren } from "../../../shared/types"; 5 | import { APP_BACKGROUND_COLOUR } from "../../../styles/theme"; 6 | 7 | export const TableHeaderContainer: FCWithChildren<{ sx?: SxProps }> = ({ children, sx }) => { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | ); 15 | }; 16 | 17 | const ContainerBox = styled("div")({ 18 | top: 0, 19 | position: "sticky", 20 | backgroundColor: APP_BACKGROUND_COLOUR, 21 | zIndex: 2, 22 | margin: "-20px -10px 5px -10px", 23 | padding: "20px 10px 0 10px", 24 | }); 25 | const HeaderCard = styled(Card)({ 26 | height: 50, 27 | display: "flex", 28 | alignItems: "center", 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/table/filters/FilterIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FilterList } from "@mui/icons-material"; 2 | import { IconButton, IconButtonProps } from "@mui/material"; 3 | import { upperFirst } from "lodash"; 4 | import React, { ReactNode } from "react"; 5 | import { withSuppressEvent } from "../../../shared/events"; 6 | import { IconType } from "../../../shared/types"; 7 | import { Intents } from "../../../styles/colours"; 8 | import { getThemeTransition } from "../../../styles/theme"; 9 | 10 | export const FilterIcon: React.FC<{ 11 | ButtonProps?: IconButtonProps; 12 | badgeContent: ReactNode; 13 | margin?: "left" | "right" | "none"; 14 | Icon?: IconType; 15 | onRightClick?: () => void; 16 | }> = ({ ButtonProps = {}, badgeContent, margin = "left", Icon = FilterList, onRightClick }) => ( 17 | (onRightClick)} 28 | > 29 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/components/table/filters/FilterMenuNestedOption.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { ChevronRight } from "@mui/icons-material"; 3 | import { Badge, ListItemIcon, ListItemText, Menu, MenuItem, Popover } from "@mui/material"; 4 | import React from "react"; 5 | import { NBSP } from "../../../shared/constants"; 6 | import { suppressEvent } from "../../../shared/events"; 7 | import { usePopoverProps } from "../../../shared/hooks"; 8 | import { FCWithChildren, IconType } from "../../../shared/types"; 9 | 10 | const PaddedListItemText = styled(ListItemText)({ 11 | paddingTop: "4px", 12 | paddingBottom: "4px", 13 | }); 14 | 15 | interface FilterMenuNestedOptionProps { 16 | icon: IconType; 17 | name: string; 18 | count: number | boolean | undefined; 19 | PopoverComponent?: typeof Menu | typeof Popover; 20 | maxHeight?: number; 21 | } 22 | export const FilterMenuNestedOptionFunction = ( 23 | { 24 | icon: Icon, 25 | name, 26 | PopoverComponent = Menu, 27 | children, 28 | count = 0, 29 | maxHeight, 30 | }: React.PropsWithChildren, 31 | ref: React.ForwardedRef 32 | ) => { 33 | const { buttonProps, popoverProps, setIsOpen } = usePopoverProps(); 34 | 35 | return ( 36 |
37 | setIsOpen(true)} 42 | onMouseLeave={() => setIsOpen(false)} 43 | > 44 | 45 | 50 | 51 | 52 | 53 | {name + NBSP + NBSP} 54 | 55 | 62 |
{children}
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | /** 70 | * The material-ui `Menu` component passes in refs to its children - this allows a function component to use the ref. 71 | */ 72 | export const FilterMenuNestedOption: FCWithChildren = 73 | React.forwardRef(FilterMenuNestedOptionFunction); 74 | -------------------------------------------------------------------------------- /src/components/table/filters/FilterMenuOption.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { CheckBox, CheckBoxOutlineBlank } from "@mui/icons-material"; 3 | import { Checkbox, ListItemText, MenuItem } from "@mui/material"; 4 | import { SxProps } from "@mui/system"; 5 | import React from "react"; 6 | import { updateListSelection } from "../../../shared/data"; 7 | import { ID } from "../../../state/shared/values"; 8 | 9 | const IconSx = { 10 | height: 20, 11 | width: 20, 12 | borderRadius: "4px", 13 | marginRight: 10, 14 | }; 15 | const OptionListItemText = styled(ListItemText)({ 16 | padding: "4px 0", 17 | 18 | "& span": { 19 | textOverflow: "ellipsis", 20 | whiteSpace: "nowrap", 21 | overflow: "hidden", 22 | }, 23 | }); 24 | 25 | interface FilterMenuOptionProps { 26 | option: T; 27 | select: (ids: ID[]) => void; 28 | selected: ID[]; 29 | getOptionIcon: (option: T, sx: SxProps) => React.ReactNode; 30 | getSecondary?: (option: T) => string; 31 | } 32 | const FilterMenuOptionFunction = ( 33 | { option, select, selected, getOptionIcon, getSecondary }: FilterMenuOptionProps, 34 | ref: React.ForwardedRef 35 | ) => { 36 | return ( 37 |
38 | select(updateListSelection(option.id, selected))} 41 | > 42 | {getOptionIcon(option, IconSx)} 43 | 44 | } 46 | checkedIcon={} 47 | style={{ marginRight: 8 }} 48 | checked={selected.includes(option.id)} 49 | color="primary" 50 | /> 51 | 52 |
53 | ); 54 | }; 55 | 56 | /** 57 | * The material-ui `Menu` component passes in refs to its children - this allows a function component to use the ref. 58 | */ 59 | export const FilterMenuOption = React.forwardRef(FilterMenuOptionFunction) as ( 60 | props: FilterMenuOptionProps 61 | ) => React.ReactElement; 62 | -------------------------------------------------------------------------------- /src/components/table/filters/RangeFilters.tsx: -------------------------------------------------------------------------------- 1 | import { Slider, SliderProps } from "@mui/material"; 2 | import { DateTime } from "luxon"; 3 | import React, { useCallback, useMemo } from "react"; 4 | import { formatNumber } from "../../../shared/data"; 5 | import { useFirstValue } from "../../../shared/hooks"; 6 | import { formatDate, getNow, parseDate, SDate } from "../../../state/shared/values"; 7 | 8 | interface DateRangeFilterProps { 9 | min?: SDate; 10 | max?: SDate; 11 | from?: SDate; 12 | to?: SDate; 13 | setRange: (from: string | undefined, to: string | undefined) => void; 14 | } 15 | export const DateRangeFilter: React.FC = ({ min, max, from: rawFrom, to: rawTo, setRange }) => { 16 | const { range, values, onChange } = useMemo(() => { 17 | const start = min ? parseDate(min) : getNow(); 18 | const getNumericValue = (date: DateTime) => date.diff(start, "days").days; 19 | const range = Math.floor(getNumericValue(max ? parseDate(max) : getNow())); 20 | const values = [ 21 | rawFrom ? getNumericValue(parseDate(rawFrom)) : 0, 22 | rawTo ? getNumericValue(parseDate(rawTo)) : range, 23 | ]; 24 | 25 | const onChange = (_: any, value: [number, number]) => { 26 | const from = value[0] === 0 ? undefined : formatDate(start.plus({ days: value[0] })); 27 | const to = value[1] === range ? undefined : formatDate(start.plus({ days: Math.floor(value[1]) })); 28 | 29 | if (from !== rawFrom || to !== rawTo) { 30 | setRange(from, to); 31 | } 32 | }; 33 | 34 | return { range, values, onChange }; 35 | }, [max, min, rawFrom, rawTo, setRange]); 36 | 37 | // Defaults shouldn't be changed after the component is initialised. 38 | const defaults = useFirstValue(values); 39 | 40 | return ( 41 | 42 | ); 43 | }; 44 | 45 | interface NumericRangeFilterProps { 46 | min?: number; 47 | max?: number; 48 | from?: number; 49 | to?: number; 50 | setRange: (from: number | undefined, to: number | undefined) => void; 51 | } 52 | export const NumericRangeFilter: React.FC = ({ min, max, from, to, setRange }) => { 53 | const onChange = useCallback( 54 | (_: any, values: [number, number]) => { 55 | const newFrom = values[0] === min ? undefined : values[0]; 56 | const newTo = values[1] === max ? undefined : values[1]; 57 | 58 | if (from !== newFrom || to !== newTo) { 59 | setRange(newFrom, newTo); 60 | } 61 | }, 62 | [max, min, from, to, setRange] 63 | ); 64 | 65 | // Defaults shouldn't be changed after the component is initialised. 66 | const defaults = useFirstValue([from || min || 0, to || max || 0]); 67 | 68 | return ( 69 | 77 | ); 78 | }; 79 | 80 | const formatLarge = (value: number) => formatNumber(value, { end: "k", maxDecimals: 0 }); 81 | -------------------------------------------------------------------------------- /src/components/table/filters/shared.ts: -------------------------------------------------------------------------------- 1 | export const filterListByID = (list: number[], value: number | undefined) => filterListByIDs(list, [value as number]); 2 | export const filterListByIDs = (list: number[], values: number[]) => 3 | list.length === 0 || values.some((value) => list.includes(value)); 4 | -------------------------------------------------------------------------------- /src/components/table/index.tsx: -------------------------------------------------------------------------------- 1 | export { TableHeaderContainer } from "./containers/TableHeaderContainer"; 2 | export { FilterIcon } from "./filters/FilterIcon"; 3 | export { FilterMenuNestedOption } from "./filters/FilterMenuNestedOption"; 4 | export { FilterMenuOption } from "./filters/FilterMenuOption"; 5 | export { filterListByID, filterListByIDs } from "./filters/shared"; 6 | export { TransactionsTable } from "./table"; 7 | -------------------------------------------------------------------------------- /src/components/table/table/types.tsx: -------------------------------------------------------------------------------- 1 | import { Transaction } from "../../../state/data"; 2 | import { ID, SDate } from "../../../state/shared/values"; 3 | 4 | // Filters 5 | export interface TransactionsTableFilters { 6 | fromDate?: SDate; 7 | toDate?: SDate; 8 | valueFrom?: number; 9 | valueTo?: number; 10 | account: ID[]; 11 | category: ID[]; 12 | currency: ID[]; 13 | statement: ID[]; 14 | hideStubs: boolean; 15 | search: string; 16 | searchRegex: boolean; 17 | tableLimit: number; 18 | } 19 | export const DefaultTransactionsTableFilters: TransactionsTableFilters = { 20 | account: [], 21 | category: [], 22 | currency: [], 23 | statement: [], 24 | hideStubs: false, 25 | search: "", 26 | searchRegex: false, 27 | tableLimit: 50, 28 | }; 29 | 30 | // Internal State 31 | 32 | /** 33 | * This holds the edit state for the table, with three state possibilities: 34 | * A new transaction is being created: "edit" is a valid Transaction, but "edit.id" is not in the main store 35 | * One transaction is being edited: "edit" is a valid Transaction, and "edit.id" is in the main store 36 | * One or more transactions are being edited in the header: 37 | * - "edit" is a Partial, where undefined corresponds to mixed values in the transactions 38 | * - "edit.id" is undefined 39 | * - "selection" contains the list of IDs being edited 40 | */ 41 | export type EditTransactionState = { [K in keyof Transaction]?: Transaction[K] }; 42 | export interface TransactionsTableState { 43 | selection: ID[]; 44 | edit?: EditTransactionState; // if "id" is undefined, then it's the header 45 | } 46 | 47 | export const DefaultTransactionsTableState: TransactionsTableState = { 48 | selection: [], 49 | }; 50 | 51 | // Fixed state state 52 | export type TransactionsTableFixedDataState = 53 | | { 54 | type: "account"; 55 | account: ID; 56 | } 57 | | { 58 | type: "category"; 59 | category: ID; 60 | nested: boolean; 61 | }; 62 | -------------------------------------------------------------------------------- /src/dialog/header.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { 3 | AccountBalance, 4 | AccountBalanceWallet, 5 | CallSplit, 6 | Clear, 7 | Description, 8 | Euro, 9 | NoteAdd, 10 | Settings, 11 | ShoppingBasket, 12 | } from "@mui/icons-material"; 13 | import { 14 | Divider, 15 | IconButton, 16 | ListItemIcon, 17 | ListItemText, 18 | MenuItem, 19 | outlinedInputClasses, 20 | Select, 21 | selectClasses, 22 | } from "@mui/material"; 23 | import { handleSelectChange } from "../shared/events"; 24 | import { IconType } from "../shared/types"; 25 | import { TopHatDispatch } from "../state"; 26 | import { AppSlice, DialogState } from "../state/app"; 27 | import { useDialogPage } from "../state/app/hooks"; 28 | import { DIALOG_OPTIONS_WIDTH } from "./shared"; 29 | 30 | export const DialogHeader: React.FC = () => { 31 | const state = useDialogPage(); 32 | 33 | return ( 34 | 35 | 43 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export const closeDialogBox = () => TopHatDispatch(AppSlice.actions.setDialogPage("closed")); 51 | const changeDialogScreen = handleSelectChange((id: DialogState["id"]) => { 52 | TopHatDispatch(AppSlice.actions.setDialogPage(id)); 53 | setTimeout(() => (document.activeElement as HTMLElement | undefined)?.blur()); 54 | }); 55 | 56 | const ExpandedListItemText = styled(ListItemText)({ marginTop: "4px !important", marginBottom: "4px !important" }); 57 | const getMenuItem = (Icon: IconType, name: string, display: string) => ( 58 | 59 | 60 | 61 | 62 | {display} 63 | 64 | ); 65 | const MenuItems = [ 66 | getMenuItem(AccountBalanceWallet, "account", "Accounts"), 67 | getMenuItem(AccountBalance, "institution", "Institutions"), 68 | getMenuItem(ShoppingBasket, "category", "Categories"), 69 | getMenuItem(Euro, "currency", "Currencies"), 70 | , 71 | getMenuItem(Description, "statement", "Statements"), 72 | getMenuItem(NoteAdd, "import", "Statement Import"), 73 | getMenuItem(CallSplit, "rule", "Rules"), 74 | , 75 | getMenuItem(Settings, "settings", "Settings"), 76 | ]; 77 | 78 | const HeaderBox = styled("div")({ 79 | height: 60, 80 | padding: "3px 8px 3px 20px", 81 | display: "flex", 82 | alignItems: "center", 83 | justifyContent: "space-between", 84 | flexShrink: 0, 85 | 86 | [`& .${outlinedInputClasses.root}:not(:hover):not(:focus-within) .${outlinedInputClasses.notchedOutline}`]: { 87 | border: "none", 88 | }, 89 | 90 | [`& .${selectClasses.select}`]: { 91 | width: DIALOG_OPTIONS_WIDTH - 32 - 18 - 20 * 2, 92 | display: "flex", 93 | alignItems: "center", 94 | padding: "5px 32px 5px 18px", 95 | }, 96 | }); 97 | const CloseIconButton = styled(IconButton)({ marginRight: 10 }); 98 | -------------------------------------------------------------------------------- /src/dialog/import/account.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { KeyboardArrowDown } from "@mui/icons-material"; 3 | import { Button, Typography } from "@mui/material"; 4 | import React from "react"; 5 | import { useGetAccountIcon } from "../../components/display/ObjectDisplay"; 6 | import { ObjectSelector } from "../../components/inputs"; 7 | import { useDialogState } from "../../state/app/hooks"; 8 | import { useAllAccounts } from "../../state/data/hooks"; 9 | import { changeStatementDialogAccount } from "../../state/logic/statement"; 10 | import { Greys } from "../../styles/colours"; 11 | 12 | export const DialogImportAccountSelector: React.FC = () => { 13 | const account = useDialogState("import", (state) => state.account); 14 | const accounts = useAllAccounts(); 15 | const getAccountIcon = useGetAccountIcon(); 16 | 17 | return ( 18 | getAccountIcon(account, IconSx)} 21 | selected={account?.id} 22 | setSelected={changeStatementDialogAccount} 23 | placeholder={ 24 | <> 25 | {getAccountIcon(undefined, IconSx)} 26 | 27 | Enter Account 28 | 29 | 30 | } 31 | > 32 | 33 | 34 | {getAccountIcon(account, IconSx)} 35 | 40 | {account?.name || "Enter Account"} 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | const AccountContainerBox = styled("div")({ margin: "12px 15px" }); 50 | const IconSx = { 51 | height: 20, 52 | width: 20, 53 | borderRadius: "4px", 54 | marginRight: 15, 55 | }; 56 | const AccountButton = styled(Button)({ 57 | height: 40, 58 | width: "100%", 59 | textTransform: "inherit", 60 | color: "inherit", 61 | }); 62 | const AccountButtonTypography = styled(Typography)({ flexGrow: 1, textAlign: "left" }); 63 | const PlaceholderSx = { 64 | fontStyle: "italic", 65 | color: Greys[600], 66 | }; 67 | -------------------------------------------------------------------------------- /src/dialog/import/contents/file.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Card } from "@mui/material"; 3 | import React from "react"; 4 | 5 | export const ImportDialogFileDisplay: React.FC<{ contents: string }> = ({ contents }) => ( 6 | 7 |
{contents}
8 |
9 | ); 10 | 11 | const ContainerCard = styled(Card)({ 12 | margin: "20px 20px 0 20px", 13 | padding: "10px 15px", 14 | overflow: "auto", 15 | 16 | "& > pre": { margin: 0 }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/dialog/import/contents/shared.tsx: -------------------------------------------------------------------------------- 1 | import { useDialogState } from "../../../state/app/hooks"; 2 | import { 3 | DialogStatementImportState, 4 | DialogStatementMappingState, 5 | DialogStatementParseState, 6 | } from "../../../state/app/statementTypes"; 7 | 8 | export const useNonFileDialogStatementState = () => 9 | useDialogState("import") as DialogStatementParseState | DialogStatementMappingState | DialogStatementImportState; 10 | -------------------------------------------------------------------------------- /src/dialog/import/contents/table/shared.tsx: -------------------------------------------------------------------------------- 1 | import { buttonClasses } from "@mui/material"; 2 | import { Greys } from "../../../../styles/colours"; 3 | 4 | export const DIALOG_IMPORT_TABLE_HEADER_STYLES = { 5 | background: Greys[200], 6 | borderBottom: "2px solid " + Greys[400], 7 | 8 | position: "sticky", 9 | top: 0, 10 | zIndex: 2, 11 | } as const; 12 | 13 | export const DIALOG_IMPORT_TABLE_ROW_STYLES = { 14 | borderTop: "1px solid " + Greys[300], 15 | } as const; 16 | 17 | export const DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES = { 18 | padding: 0, 19 | 20 | [`& .${buttonClasses.endIcon}`]: { 21 | marginLeft: "-1px !important", 22 | }, 23 | } as const; 24 | -------------------------------------------------------------------------------- /src/dialog/import/contents/table/transfer.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { ImportExport } from "@mui/icons-material"; 3 | import { Button, ButtonBase, Collapse, svgIconClasses, Typography } from "@mui/material"; 4 | import React, { useMemo } from "react"; 5 | import { withSuppressEvent } from "../../../../shared/events"; 6 | import { Transaction } from "../../../../state/data"; 7 | import { toggleStatementRowTransfer } from "../../../../state/logic/statement"; 8 | import { DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES } from "./shared"; 9 | 10 | export const DialogImportTableTransferDisplay: React.FC<{ 11 | transfer: { 12 | transaction?: Transaction; 13 | excluded?: boolean; 14 | }; 15 | disabled: boolean; 16 | transfers?: boolean; 17 | file: string; 18 | row: number; 19 | }> = ({ disabled, transfer: { transaction, excluded }, transfers, file, row }) => { 20 | const onClick = useMemo(() => withSuppressEvent(() => toggleStatementRowTransfer(file, row)), [file, row]); 21 | if (!transaction) return null; 22 | 23 | return ( 24 | 25 | 35 | } 38 | color={excluded || disabled ? "inherit" : undefined} 39 | onClick={onClick} 40 | /> 41 | {transaction.date} 42 | 43 | {transaction.summary || transaction.reference} 44 | 45 | 46 | {transaction.value} 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | const ContainerCollapse = styled(Collapse)({ gridColumnStart: "start", gridColumnEnd: "end" }); 54 | const ButtonBaseSx = { 55 | transfer: { 56 | display: "flex", 57 | alignItems: "center", 58 | marginBottom: 2, 59 | width: "max-content", 60 | padding: "0 5px", 61 | borderRadius: "2px", 62 | marginLeft: 28, 63 | }, 64 | disabled: { opacity: 0.5 }, 65 | excluded: { "&:not(:hover)": { opacity: 0.5 } }, 66 | }; 67 | const TransferButton = styled(Button)({ 68 | ...DIALOG_IMPORT_TABLE_ICON_BUTTON_STYLES, 69 | minWidth: 14, 70 | 71 | [`& .${svgIconClasses.root}`]: { 72 | fontSize: "14px !important", 73 | }, 74 | }); 75 | const TextTypography = styled(Typography)({ marginLeft: 20, maxWidth: 200 }); 76 | -------------------------------------------------------------------------------- /src/dialog/import/contents/tabs.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Clear } from "@mui/icons-material"; 3 | import { alpha, IconButton, Tab, tabClasses, Tabs } from "@mui/material"; 4 | import React from "react"; 5 | import { withSuppressEvent } from "../../../shared/events"; 6 | import { changeFileSelection, removeStatementFileFromDialog } from "../../../state/logic/statement"; 7 | import { Greys, Intents, WHITE } from "../../../styles/colours"; 8 | import { useNonFileDialogStatementState } from "./shared"; 9 | 10 | export const ImportDialogFileTabs: React.FC = () => { 11 | const state = useNonFileDialogStatementState(); 12 | const currentFileParsed = state.columns.all[state.file].matches; 13 | 14 | return ( 15 | 22 | {state.files.map((file) => ( 23 | {file.name}} 26 | value={file.id} 27 | sx={state.columns.all[file.id].matches ? undefined : { color: Intents.danger.main }} 28 | icon={ 29 | (() => removeStatementFileFromDialog(file.id))} 32 | sx={ 33 | state.columns.all[file.id].matches 34 | ? undefined 35 | : { color: alpha(Intents.danger.main, 0.6) } 36 | } 37 | > 38 | 39 | 40 | } 41 | component="div" 42 | wrapped={true} 43 | /> 44 | ))} 45 | 46 | ); 47 | }; 48 | 49 | const onFileChange = (_: React.SyntheticEvent, id: string) => changeFileSelection(id); 50 | 51 | const ContainerTabs = styled(Tabs)({ 52 | background: WHITE, 53 | borderBottom: "1px solid " + Greys[400], 54 | flexShrink: 0, 55 | 56 | [`& .${tabClasses.root}`]: { 57 | minHeight: 48, 58 | padding: "0 18px", 59 | flexDirection: "row-reverse", 60 | }, 61 | }); 62 | const TabIconButton = styled(IconButton)({ margin: "0 -5px 0 5px !important" }); 63 | const TabLabelWrapperSpan = styled("span")({ overflow: "hidden", textOverflow: "ellipsis", maxWidth: 150 }); 64 | -------------------------------------------------------------------------------- /src/dialog/import/index.tsx: -------------------------------------------------------------------------------- 1 | import { useDialogState } from "../../state/app/hooks"; 2 | import { DialogImportScreen } from "./import"; 3 | import { DialogImportFileScreen } from "./upload"; 4 | 5 | export const DialogImportView: React.FC = () => { 6 | const page = useDialogState("import", (state) => state.page); 7 | return page === "file" ? : ; 8 | }; 9 | -------------------------------------------------------------------------------- /src/dialog/import/steps/final.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Checkbox, StepContent, Tooltip, Typography } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { handleCheckboxChange } from "../../../shared/events"; 4 | import { TopHatDispatch, TopHatStore } from "../../../state"; 5 | import { AppSlice } from "../../../state/app"; 6 | import { useDialogState } from "../../../state/app/hooks"; 7 | import { DialogStatementImportState } from "../../../state/app/statementTypes"; 8 | import { 9 | canImportStatementsAndClearDialog, 10 | goBackToStatementMapping, 11 | importStatementsAndClearDialog, 12 | } from "../../../state/logic/statement"; 13 | import { DialogImportActionsBox, DialogImportOptionBox, DialogImportOptionsContainerBox } from "./shared"; 14 | 15 | export const DialogImportImportStepContent: React.FC<{ 16 | shouldDetectTransfers: boolean; 17 | setShouldDetectTransfers: (value: boolean) => void; 18 | }> = ({ shouldDetectTransfers, setShouldDetectTransfers }) => { 19 | const [shouldRunRules, setShouldRunRules] = useState(true); 20 | const reversed = useDialogState("import", (state) => (state as DialogStatementImportState).reverse); 21 | 22 | return ( 23 | 24 | 25 | 26 | Include Transfers 27 | 33 | 34 | 35 | Run Import Rules 36 | 42 | 43 | 44 | Reverse Transaction Order 45 | 46 | 47 | 48 | 49 | 52 | 53 |
54 | 62 |
63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | const toggleReverseOrder = handleCheckboxChange((reverse) => { 70 | const current = TopHatStore.getState().app.dialog.import as DialogStatementImportState; 71 | TopHatDispatch( 72 | AppSlice.actions.setDialogPartial({ 73 | id: "import", 74 | import: { 75 | ...current, 76 | reverse, 77 | }, 78 | }) 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /src/dialog/import/steps/parse.tsx: -------------------------------------------------------------------------------- 1 | import { Help } from "@mui/icons-material"; 2 | import { Button, Checkbox, IconButton, StepContent, Tooltip, Typography } from "@mui/material"; 3 | import { handleTextFieldChange } from "../../../shared/events"; 4 | import { DialogStatementParseState } from "../../../state/app/statementTypes"; 5 | import { 6 | canGoToStatementMappingScreen, 7 | changeStatementParsing, 8 | goToStatementMappingScreen, 9 | removeAllStatementFiles, 10 | toggleStatementHasHeader, 11 | } from "../../../state/logic/statement"; 12 | import { Greys } from "../../../styles/colours"; 13 | import { 14 | DialogImportActionsBox, 15 | DialogImportInputTextField, 16 | DialogImportOptionBox, 17 | DialogImportOptionsContainerBox, 18 | DialogImportOptionTitleContainerBox, 19 | } from "./shared"; 20 | 21 | export const DialogImportParseStepContent: React.FC<{ state: DialogStatementParseState }> = ({ state }) => ( 22 | 23 | 24 | 25 | Header Row 26 | 32 | 33 | 34 | Delimiter 35 | 42 | 43 | 44 | 45 | Date Format 46 | 47 | 52 | 53 | 54 | 55 | 56 | 63 | 64 | 65 | 66 | 69 | 70 |
71 | 79 |
80 |
81 |
82 |
83 | ); 84 | 85 | const changeDelimiter = handleTextFieldChange((value) => changeStatementParsing({ delimiter: value || undefined })); 86 | const changeDateFormat = handleTextFieldChange((value) => changeStatementParsing({ dateFormat: value || undefined })); 87 | -------------------------------------------------------------------------------- /src/dialog/import/steps/shared.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { inputClasses, TextField } from "@mui/material"; 3 | import { Greys } from "../../../styles/colours"; 4 | 5 | export const DialogImportOptionsContainerBox = styled("div")({ maxHeight: 220, overflowY: "auto" }); 6 | 7 | export const DialogImportOptionBox = styled("div")({ 8 | height: 40, 9 | display: "flex", 10 | marginRight: 19, 11 | alignItems: "center", 12 | 13 | "& p": { color: Greys[900] }, 14 | "& > p:first-of-type": { flexGrow: 1 }, 15 | }); 16 | 17 | export const DialogImportOptionTitleContainerBox = styled("div")({ 18 | flexGrow: 1, 19 | display: "flex", 20 | alignItems: "center", 21 | 22 | "& p": { marginRight: 3 }, 23 | }); 24 | 25 | export const DialogImportActionsBox = styled("div")({ 26 | display: "flex", 27 | float: "right", 28 | marginTop: 15, 29 | marginRight: 19, 30 | 31 | "& > *": { marginRight: "15px !important" }, 32 | }); 33 | 34 | export const DialogImportInputTextField = styled(TextField)({ 35 | width: 120, 36 | marginTop: 4, 37 | 38 | [`& .${inputClasses.input}`]: { textAlign: "center" }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog } from "@mui/material"; 2 | import { get } from "lodash"; 3 | import { useCallback, useContext, useEffect, useState } from "react"; 4 | import { FileHandlerContext } from "../app/context"; 5 | import { useDialogPage } from "../state/app/hooks"; 6 | import { closeDialogBox, DialogHeader } from "./header"; 7 | import { DialogImportView } from "./import"; 8 | import { DialogAccountsView } from "./objects/accounts"; 9 | import { DialogCategoriesView } from "./objects/categories"; 10 | import { DialogCurrenciesView } from "./objects/currencies"; 11 | import { DialogInstitutionsView } from "./objects/institutions"; 12 | import { DialogRulesView } from "./objects/rules"; 13 | import { DialogStatementView } from "./objects/statements"; 14 | import { DialogSettingsView } from "./settings"; 15 | import { DialogMain } from "./shared"; 16 | 17 | export const TopHatDialog: React.FC = () => { 18 | const state = useDialogPage(); 19 | const { dropzoneRef, isDragActive } = useContext(FileHandlerContext); 20 | 21 | const onClose = useCallback(() => !isDragActive && closeDialogBox(), [isDragActive]); 22 | 23 | // This triggers a re-render after initial load, once the ref is populated 24 | const reRender = useState(false)[1]; 25 | useEffect(() => void setTimeout(() => reRender(true), 0.1), [reRender]); 26 | 27 | if (!dropzoneRef?.current) return null; 28 | 29 | return ( 30 | 36 | 37 | {isDragActive ? DialogPages.import : get(DialogPages, state, )} 38 | 39 | ); 40 | }; 41 | 42 | const DialogPages = { 43 | account: , 44 | institution: , 45 | category: , 46 | currency: , 47 | rule: , 48 | statement: , 49 | import: , 50 | settings: , 51 | } as const; 52 | 53 | const DialogPaperSxProps = { 54 | sx: { 55 | width: 900, 56 | maxWidth: "inherit", 57 | height: 600, 58 | overflow: "hidden", 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/dialog/objects/shared/draggable.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { Menu } from "@mui/icons-material"; 3 | import { List, MenuItem } from "@mui/material"; 4 | import React from "react"; 5 | import { 6 | DragDropContext, 7 | Draggable, 8 | DraggableProvided, 9 | DraggableStateSnapshot, 10 | Droppable, 11 | DroppableProvided, 12 | DropResult, 13 | } from "react-beautiful-dnd"; 14 | import { withSuppressEvent } from "../../../shared/events"; 15 | import { useDialogState } from "../../../state/app/hooks"; 16 | import { BasicObjectType } from "../../../state/data/types"; 17 | import { Greys } from "../../../styles/colours"; 18 | import { DialogOptions } from "../../shared/layout"; 19 | import { 20 | DialogObjectOptionsBox, 21 | DialogObjectSelectorProps, 22 | DialogSelectorAddNewButton, 23 | useObjectsWithExclusionList, 24 | } from "./shared"; 25 | import { getUpdateFunctions } from "./update"; 26 | export { ObjectEditContainer } from "./edit"; 27 | export { getUpdateFunctions } from "./update"; 28 | 29 | export const DraggableDialogObjectSelector = ({ 30 | type, 31 | exclude, 32 | createDefaultOption, 33 | onAddNew, 34 | render, 35 | onDragEnd, 36 | }: DialogObjectSelectorProps & { onDragEnd: (result: DropResult) => void }) => { 37 | const selected = useDialogState(type, (object) => object?.id); 38 | const options = useObjectsWithExclusionList(type, exclude); 39 | const functions = getUpdateFunctions(type); 40 | 41 | const getMenuItem = (option: BasicObjectType[Name]) => ( 42 | 43 | {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( 44 | (() => functions.set(option))} 48 | {...provided.draggableProps} 49 | ref={provided.innerRef} 50 | > 51 | {render(option)} 52 | {provided && ( 53 | 54 | 55 | 56 | )} 57 | 58 | )} 59 | 60 | ); 61 | 62 | const getList = (provided: DroppableProvided) => ( 63 | 64 | {options.map(getMenuItem)} 65 | {provided.placeholder} 66 | 67 | ); 68 | 69 | return ( 70 | 71 | 72 | 73 | {getList} 74 | 75 | 76 | {(createDefaultOption || onAddNew) && ( 77 | (onAddNew ? onAddNew() : functions.set(createDefaultOption!()))} 79 | type={type} 80 | /> 81 | )} 82 | 83 | ); 84 | }; 85 | const HandleBox = styled("div")({ display: "flex", alignItems: "center", marginRight: 5 }); 86 | -------------------------------------------------------------------------------- /src/dialog/objects/shared/index.tsx: -------------------------------------------------------------------------------- 1 | export { DraggableDialogObjectSelector } from "./draggable"; 2 | export { ObjectEditContainer } from "./edit"; 3 | export { BasicDialogObjectSelector } from "./selector"; 4 | export { DialogObjectOptionsBox, DialogSelectorAddNewButton } from "./shared"; 5 | export { getUpdateFunctions } from "./update"; 6 | -------------------------------------------------------------------------------- /src/dialog/objects/shared/selector.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem, MenuList } from "@mui/material"; 2 | import React from "react"; 3 | import { withSuppressEvent } from "../../../shared/events"; 4 | import { useDialogState } from "../../../state/app/hooks"; 5 | import { BasicObjectName } from "../../../state/data/types"; 6 | import { DialogOptions } from "../../shared/layout"; 7 | import { 8 | DialogObjectOptionsBox, 9 | DialogObjectSelectorProps, 10 | DialogSelectorAddNewButton, 11 | useObjectsWithExclusionList, 12 | } from "./shared"; 13 | import { getUpdateFunctions } from "./update"; 14 | 15 | export const BasicDialogObjectSelector = ({ 16 | type, 17 | exclude, 18 | createDefaultOption, 19 | onAddNew, 20 | render, 21 | }: DialogObjectSelectorProps) => { 22 | const selected = useDialogState(type, (object) => object?.id); 23 | const options = useObjectsWithExclusionList(type, exclude); 24 | const functions = getUpdateFunctions(type); 25 | 26 | return ( 27 | 28 | 29 | 30 | {options.map((option) => ( 31 | (() => functions.set(option))} 35 | > 36 | {render(option)} 37 | 38 | ))} 39 | 40 | 41 | {(createDefaultOption || onAddNew) && ( 42 | (onAddNew ? onAddNew() : functions.set(createDefaultOption!()))} 44 | type={type} 45 | /> 46 | )} 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/dialog/objects/shared/shared.tsx: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | import { AddCircleOutline } from "@mui/icons-material"; 3 | import { Button } from "@mui/material"; 4 | import { upperFirst } from "lodash"; 5 | import React from "react"; 6 | import { withSuppressEvent } from "../../../shared/events"; 7 | import { useAllObjects } from "../../../state/data/hooks"; 8 | import { BasicObjectName, BasicObjectType } from "../../../state/data/types"; 9 | import { ID } from "../../../state/shared/values"; 10 | export { ObjectEditContainer } from "./edit"; 11 | export { getUpdateFunctions } from "./update"; 12 | 13 | export interface DialogObjectSelectorProps { 14 | type: Name; 15 | exclude?: ID[]; 16 | createDefaultOption?: () => BasicObjectType[Name]; 17 | onAddNew?: () => void; 18 | render: (option: BasicObjectType[Name]) => React.ReactNode; 19 | } 20 | 21 | export const useObjectsWithExclusionList = (type: Name, exclude?: ID[]) => { 22 | const options = useAllObjects(type); 23 | return exclude ? options.filter(({ id }) => !exclude.includes(id)) : options; 24 | }; 25 | 26 | export const DialogObjectOptionsBox = styled("div")({ 27 | overflowY: "auto", 28 | flexGrow: 1, 29 | marginTop: 5, 30 | }); 31 | 32 | export const DialogSelectorAddNewButton: React.FC<{ onClick: () => void; type: string }> = ({ onClick, type }) => ( 33 | } 36 | onClick={withSuppressEvent(onClick)} 37 | > 38 | New {upperFirst(type)} 39 | 40 | ); 41 | const DialogSelectorBottomButton = styled(Button)({ margin: 20 }); 42 | -------------------------------------------------------------------------------- /src/dialog/objects/shared/update.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from "lodash"; 2 | import { TopHatDispatch, TopHatStore } from "../../../state"; 3 | import { AppSlice } from "../../../state/app"; 4 | import { DataSlice } from "../../../state/data"; 5 | import { BasicObjectName, BasicObjectType } from "../../../state/data/types"; 6 | import { ID } from "../../../state/shared/values"; 7 | 8 | export const getUpdateFunctions = (type: Type) => { 9 | type Option = BasicObjectType[Type]; 10 | 11 | const get = (id: ID) => TopHatStore.getState().data[type].entities[Number(id)] as Option; 12 | const getWorkingCopy = () => cloneDeep(TopHatStore.getState().app.dialog[type] as Option); 13 | const set = (option?: Option) => TopHatDispatch(AppSlice.actions.setDialogPartial({ id: type, [type]: option })); 14 | const setPartial = (partial?: Partial